# [GRAPHIQUE] DataInsight Pro — PARTIE 1
## Setup, Découverte du Dataset & Architecture Professionnelle

---

# 1⃣ CONTEXTE MÉTIER RÉEL

## L'Entreprise : Olist — E-Commerce Brésilien

**Olist** est une startup brésilienne fondée en 2015, considérée comme le "Amazon brésilien des petits vendeurs". Elle connecte des milliers de marchands à ses partenaires logistiques via une plateforme unique. En 2018, Olist a rendu public son dataset transactionnel sur Kaggle, l'un des datasets e-commerce les plus utilisés au monde en data science.

### Problématique Business

L'équipe data d'Olist vous a recruté comme **Data Scientist Junior**. Votre première mission :

> "Nous avons des millions de transactions, mais nous ne savons pas vraiment où nous gagnons de l'argent, pourquoi certains clients partent, et quels produits méritent plus de visibilité. Construisez un pipeline d'analyse complet pour nous aider à prendre de meilleures décisions."

### Questions Business à Résoudre (tout au long du projet)
1. Quels sont nos produits et catégories les plus rentables ?
2. Quelle est la satisfaction client (scores, délais de livraison) ?
3. Y a-t-il des tendances saisonnières dans nos ventes ?
4. Quels États brésiliens génèrent le plus de revenus ?
5. Peut-on prédire si une commande sera livrée en retard ?

---

# 2⃣ OBJECTIFS PÉDAGOGIQUES

À la fin de cette Partie 1, vous saurez :

[OK] Configurer un environnement Python professionnel avec venv  
[OK] Comprendre la structure d'un projet data maintenable  
[OK] Télécharger et charger un dataset réel de 100 000+ lignes  
[OK] Effectuer une inspection initiale complète (shape, dtypes, NaN)  
[OK] Comprendre chaque colonne métier du dataset  
[OK] Écrire du code Python modulaire et documenté  

**Concepts utilisés :** variables d'environnement, imports, pathlib, pandas.read_csv, df.info(), df.describe(), df.head(), df.dtypes, df.isnull()

---

# 3⃣ LE DATASET RÉEL — Brazilian E-Commerce (Olist)

## [LIEN] Source Officielle

**URL Kaggle :** https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce  
**Licence :** CC BY-NC-SA 4.0  
**Taille :** ~130 MB (fichiers CSV multiples)  
**Période :** Septembre 2016 -> Août 2018  

## Fichiers du Dataset

Le dataset Olist est constitué de **9 fichiers CSV** interconnectés par des clés étrangères (comme une base de données relationnelle) :

```
olist_orders_dataset.csv              ->  99,441 commandes
olist_order_items_dataset.csv         -> 112,650 items de commandes
olist_customers_dataset.csv           ->  99,441 clients uniques
olist_products_dataset.csv            ->  32,951 produits
olist_sellers_dataset.csv             ->   3,095 vendeurs
olist_order_payments_dataset.csv      -> 103,886 paiements
olist_order_reviews_dataset.csv       ->  99,224 avis clients
olist_geolocation_dataset.csv         ->   1,000,163 coordonnées GPS
product_category_name_translation.csv ->      71 traductions catégories
```

## Schéma Relationnel

```
customers ──────────────────────────────────────────────┐
  customer_id                                           │
  customer_unique_id                                    │
  customer_zip_code_prefix                              │
  customer_city                                         │
  customer_state                                        │
                                                        [BLACK_DOWN-POINTING_TRIANGLE]
orders ──────────────────────────── order_items ──── products
  order_id (PK)                       order_id (FK)     product_id (PK)
  customer_id (FK) [BLACK_LEFT-POINTING_POINTER]──────────        order_item_id     product_category_name
  order_status                        product_id (FK)   product_weight_g
  order_purchase_timestamp            seller_id (FK)    product_length_cm
  order_approved_at                   shipping_limit_date
  order_delivered_carrier_date        price
  order_delivered_customer_date       freight_value
  order_estimated_delivery_date
        │
        ├──────────────────────── payments
        │                          order_id (FK)
        │                          payment_sequential
        │                          payment_type
        │                          payment_installments
        │                          payment_value
        │
        └──────────────────────── reviews
                                   review_id (PK)
                                   order_id (FK)
                                   review_score (1-5)
                                   review_comment_title
                                   review_comment_message
                                   review_creation_date
```

---

# 4⃣ ANALYSE DU DATASET — COLONNE PAR COLONNE

## Fichier Principal : olist_orders_dataset.csv

| Colonne | Type | Signification Métier | Problèmes Possibles |
|---------|------|---------------------|---------------------|
| `order_id` | string (UUID) | Identifiant unique de la commande | Doublons si jointures mal faites |
| `customer_id` | string (UUID) | Identifiant du client pour cette commande | ≠ customer_unique_id (un client peut avoir plusieurs customer_id) |
| `order_status` | catégoriel | État de la commande : delivered, shipped, canceled, unavailable... | Valeurs mixtes à standardiser |
| `order_purchase_timestamp` | datetime | Moment exact de la commande | Souvent string -> conversion nécessaire |
| `order_approved_at` | datetime | Quand le paiement a été approuvé | NaN si commande non approuvée |
| `order_delivered_carrier_date` | datetime | Quand le transporteur a récupéré le colis | NaN si non livré |
| `order_delivered_customer_date` | datetime | Quand le client a reçu son colis | NaN si non livré -> 8% de NaN |
| `order_estimated_delivery_date` | datetime | Date de livraison promise | Toujours présent -> utile pour calculer retard |

## Fichier : olist_order_items_dataset.csv

| Colonne | Type | Signification | Problèmes |
|---------|------|---------------|-----------|
| `order_id` | string | Référence la commande | Clé étrangère -> NaN si jointure échouée |
| `order_item_id` | int | Numéro de l'article dans la commande (1, 2, 3...) | Une commande peut avoir plusieurs articles |
| `product_id` | string | Référence le produit | Produits orphelins possibles |
| `seller_id` | string | Référence le vendeur | Vendeurs inactifs possibles |
| `shipping_limit_date` | datetime | Date limite d'expédition pour le vendeur | Utile pour calculer la performance vendeur |
| `price` | float | Prix unitaire du produit (BRL) | Outliers : prix très élevés ou 0 |
| `freight_value` | float | Coût de livraison (BRL) | Peut être 0 pour livraison gratuite |

## Fichier : olist_order_reviews_dataset.csv

| Colonne | Type | Signification | Problèmes |
|---------|------|---------------|-----------|
| `review_id` | string | ID unique de l'avis | |
| `order_id` | string | Référence la commande | Jointure parfois manquante |
| `review_score` | int (1-5) | Note donnée par le client | Distribution bimodale (1 et 5 dominants) |
| `review_comment_title` | string | Titre du commentaire | 60%+ de NaN (optionnel) |
| `review_comment_message` | string | Corps du commentaire | 40%+ de NaN, texte en portugais |
| `review_creation_date` | datetime | Quand l'avis a été créé | |
| `review_answer_timestamp` | datetime | Quand Olist a répondu | |

---

# 5⃣ STRUCTURE PROFESSIONNELLE DU PROJET

## Architecture des Fichiers

```
datainsight_pro/
│
├── data/
│   ├── raw/                        <- Données brutes originales (NE PAS MODIFIER)
│   │   ├── olist_orders_dataset.csv
│   │   ├── olist_order_items_dataset.csv
│   │   ├── olist_customers_dataset.csv
│   │   ├── olist_products_dataset.csv
│   │   ├── olist_sellers_dataset.csv
│   │   ├── olist_order_payments_dataset.csv
│   │   ├── olist_order_reviews_dataset.csv
│   │   ├── olist_geolocation_dataset.csv
│   │   └── product_category_name_translation.csv
│   │
│   ├── processed/                  <- Données nettoyées et transformées
│   │   ├── orders_clean.parquet
│   │   ├── master_dataset.parquet  <- Dataset final fusionné
│   │   └── features_engineered.parquet
│   │
│   └── external/                   <- Données tierces (taux de change, etc.)
│
├── notebooks/
│   ├── 01_exploration.ipynb        <- Exploration initiale
│   ├── 02_nettoyage.ipynb          <- Data cleaning
│   ├── 03_eda.ipynb                <- Analyse exploratoire
│   ├── 04_visualisation.ipynb      <- Dashboards
│   ├── 05_analyse_avancee.ipynb    <- Analyses métier
│   └── 06_machine_learning.ipynb   <- Modèles ML
│
├── src/
│   ├── __init__.py                 <- Fait de src/ un package Python
│   ├── data_loader.py              <- Chargement et fusion des fichiers
│   ├── data_cleaning.py            <- Nettoyage et validation
│   ├── analysis.py                 <- Analyses statistiques et métriques
│   ├── visualization.py            <- Fonctions de visualisation
│   └── utils.py                    <- Fonctions utilitaires transversales
│
├── reports/
│   ├── figures/                    <- Graphiques sauvegardés (PNG, PDF)
│   └── final_report.md             <- Rapport de synthèse
│
├── tests/
│   ├── test_data_loader.py
│   └── test_data_cleaning.py
│
├── .env                            <- Variables d'environnement (NE PAS committer)
├── .gitignore
├── requirements.txt
├── README.md
└── main.py                         <- Point d'entrée du projet
```

## Explication de Chaque Fichier

### `src/data_loader.py`
**Rôle :** Responsable UNIQUEMENT du chargement des données.  
**Responsabilité unique (SRP)** : charger, fusionner, valider l'intégrité des jointures.  
**Ne fait pas :** le nettoyage, les calculs métier, la visualisation.

### `src/data_cleaning.py`
**Rôle :** Transforme les données brutes en données exploitables.  
**Responsabilité :** NaN, doublons, outliers, types, normalisation.  
**Principe :** Reçoit un DataFrame brut, retourne un DataFrame propre.

### `src/analysis.py`
**Rôle :** Calcule les métriques et KPIs business.  
**Responsabilité :** CA, satisfaction client, délais, segmentation.  
**Principe :** Fonctions pures (même entrée -> même sortie, pas d'effet de bord).

### `src/visualization.py`
**Rôle :** Génère et sauvegarde tous les graphiques.  
**Responsabilité :** Matplotlib, Seaborn, Plotly.  
**Principe :** Chaque fonction retourne une figure, ne fait pas plt.show() directement.

### `src/utils.py`
**Rôle :** Fonctions utilitaires partagées.  
**Responsabilité :** logging, timing, formatage, validation des chemins.

---

# 6⃣ IMPLÉMENTATION COMPLÈTE — PARTIE 1

## 6.1 — requirements.txt

```text
# ============================================================
# DataInsight Pro — Dépendances Python
# Version : 1.0.0
# ============================================================

# CORE DATA SCIENCE
numpy==1.26.4
pandas==2.2.1
scipy==1.13.0

# VISUALISATION
matplotlib==3.8.4
seaborn==0.13.2
plotly==5.20.0

# MACHINE LEARNING
scikit-learn==1.4.2
joblib==1.4.0

# FICHIERS & UTILITAIRES
openpyxl==3.1.2        # Lecture/écriture Excel
pyarrow==16.0.0        # Format Parquet
python-dotenv==1.0.1   # Variables d'environnement
tqdm==4.66.2           # Barres de progression
loguru==0.7.2          # Logging avancé

# DÉVELOPPEMENT
jupyter==1.0.0
jupyterlab==4.1.6
ipywidgets==8.1.2

# OPTIONNEL (analyses avancées)
statsmodels==0.14.1    # Statistiques avancées
```

**Commande d'installation :**
```bash
pip install -r requirements.txt
```

---

## 6.2 — src/utils.py (Fonctions Utilitaires)

```python
# ============================================================
# src/utils.py
# DataInsight Pro — Fonctions Utilitaires Transversales
# ============================================================
# Ce fichier contient des fonctions réutilisées dans tout le
# projet : logging, timing, formatage, validation.
# ============================================================

import time                     # Pour mesurer le temps d'exécution
import functools                # Pour les décorateurs
from pathlib import Path        # Gestion moderne des chemins (multi-OS)
from datetime import datetime   # Manipulation des dates
import pandas as pd             # Manipulation de données
import numpy as np              # Calcul numérique


# ─────────────────────────────────────────────────────────────
# CONSTANTES DU PROJET
# ─────────────────────────────────────────────────────────────

# Path(__file__) -> chemin absolu de ce fichier (utils.py)
# .parent -> dossier src/
# .parent -> dossier datainsight_pro/
ROOT_DIR = Path(__file__).parent.parent

# Sous-dossiers importants
DATA_RAW_DIR       = ROOT_DIR / "data" / "raw"
DATA_PROCESSED_DIR = ROOT_DIR / "data" / "processed"
REPORTS_DIR        = ROOT_DIR / "reports"
FIGURES_DIR        = REPORTS_DIR / "figures"

# Devise du dataset (Real brésilien)
CURRENCY = "BRL"
CURRENCY_SYMBOL = "R$"


# ─────────────────────────────────────────────────────────────
# FONCTION : CRÉER LES DOSSIERS NÉCESSAIRES
# ─────────────────────────────────────────────────────────────

def creer_structure_projet():
    """
    Crée tous les dossiers nécessaires au projet s'ils n'existent pas.
    
    Utilise mkdir(parents=True, exist_ok=True) pour :
      - parents=True  : créer les dossiers parents si nécessaire
      - exist_ok=True : ne pas lever d'erreur si le dossier existe déjà
    
    Returns:
        None
    
    Example:
        >>> creer_structure_projet()
        [OK] Structure créée/vérifiée
    """
    dossiers = [
        DATA_RAW_DIR,
        DATA_PROCESSED_DIR,
        DATA_PROCESSED_DIR.parent / "external",
        REPORTS_DIR,
        FIGURES_DIR,
    ]
    
    for dossier in dossiers:
        # mkdir -> créer le dossier
        # parents=True -> crée data/ puis data/raw/ automatiquement
        # exist_ok=True -> pas d'erreur si le dossier existe déjà
        dossier.mkdir(parents=True, exist_ok=True)
    
    print("[OK] Structure de projet créée/vérifiée")
    print(f"   Racine        : {ROOT_DIR}")
    print(f"   Données brutes: {DATA_RAW_DIR}")
    print(f"   Données proc. : {DATA_PROCESSED_DIR}")
    print(f"   Rapports      : {REPORTS_DIR}")


# ─────────────────────────────────────────────────────────────
# DÉCORATEUR : MESURE DU TEMPS D'EXÉCUTION
# ─────────────────────────────────────────────────────────────

def timeit(func):
    """
    Décorateur qui mesure et affiche le temps d'exécution d'une fonction.
    
    Un décorateur est une fonction qui "enveloppe" une autre fonction
    pour ajouter du comportement sans modifier le code original.
    
    Syntaxe d'utilisation :
        @timeit
        def ma_fonction():
            ...
    
    Args:
        func: La fonction à chronométrer
    
    Returns:
        La fonction wrappée avec mesure du temps
    
    Example:
        @timeit
        def charger_donnees():
            return pd.read_csv("gros_fichier.csv")
        # Affichera : [TIMER] charger_donnees : 2.341s
    """
    @functools.wraps(func)  # Préserve le nom et la docstring de la fonction originale
    def wrapper(*args, **kwargs):
        # *args -> arguments positionnels de la fonction
        # **kwargs -> arguments nommés (keyword arguments)
        
        debut = time.perf_counter()         # Démarrer le chrono (haute précision)
        resultat = func(*args, **kwargs)    # Exécuter la vraie fonction
        fin = time.perf_counter()           # Arrêter le chrono
        
        duree = fin - debut                 # Calculer la durée en secondes
        
        # f-string : {func.__name__} -> nom de la fonction
        # {duree:.3f}s -> durée avec 3 décimales + "s"
        print(f"[[TEMPS]  TIMER] {func.__name__} : {duree:.3f}s")
        
        return resultat  # Retourner le résultat de la fonction originale
    
    return wrapper  # Retourner la fonction enveloppée


# ─────────────────────────────────────────────────────────────
# FONCTION : FORMATER LES NOMBRES POUR L'AFFICHAGE
# ─────────────────────────────────────────────────────────────

def formater_nombre(nombre: float, decimales: int = 2, devise: bool = False) -> str:
    """
    Formate un nombre pour l'affichage lisible.
    
    Args:
        nombre  : Le nombre à formater
        decimales: Nombre de décimales
        devise  : Si True, ajoute le symbole monétaire BRL
    
    Returns:
        Chaîne formatée (ex: "1,234,567.89" ou "R$ 1,234,567.89")
    
    Examples:
        >>> formater_nombre(1234567.891)
        '1,234,567.89'
        >>> formater_nombre(1234567.891, devise=True)
        'R$ 1,234,567.89'
    """
    # f"{ :,.Xf}" -> formatage Python :
    # , -> séparateur de milliers (1,000,000)
    # .Xf -> X décimales
    nombre_formate = f"{nombre:,.{decimales}f}"
    
    if devise:
        # Préfixer avec le symbole de devise
        return f"{CURRENCY_SYMBOL} {nombre_formate}"
    
    return nombre_formate


# ─────────────────────────────────────────────────────────────
# FONCTION : RAPPORT DE QUALITÉ DES DONNÉES
# ─────────────────────────────────────────────────────────────

def rapport_qualite(df: pd.DataFrame, nom: str = "DataFrame") -> pd.DataFrame:
    """
    Génère un rapport complet sur la qualité d'un DataFrame.
    
    Calcule pour chaque colonne :
    - Nombre et % de valeurs manquantes
    - Nombre de valeurs uniques
    - Type de données
    - Exemples de valeurs
    
    Args:
        df  : Le DataFrame à analyser
        nom : Nom descriptif pour l'affichage
    
    Returns:
        DataFrame de rapport avec une ligne par colonne
    
    Example:
        >>> df = pd.read_csv("orders.csv")
        >>> rapport = rapport_qualite(df, "Commandes")
    """
    print(f"\n{'='*65}")
    print(f"  RAPPORT QUALITÉ — {nom.upper()}")
    print(f"{'='*65}")
    
    # Dimensions du DataFrame
    n_lignes, n_cols = df.shape
    print(f"  Dimensions : {n_lignes:,} lignes × {n_cols} colonnes")
    
    # Mémoire utilisée
    # memory_usage(deep=True) -> compte la mémoire réelle des strings
    mem_mb = df.memory_usage(deep=True).sum() / 1024**2
    print(f"  Mémoire    : {mem_mb:.2f} MB")
    
    # Construire le rapport colonne par colonne
    rapport_data = []
    
    for colonne in df.columns:
        serie = df[colonne]
        
        n_manquants = serie.isnull().sum()                   # Nombre de NaN
        pct_manquants = n_manquants / n_lignes * 100        # Pourcentage NaN
        n_uniques = serie.nunique()                          # Valeurs distinctes
        
        # Exemples de valeurs (les 3 premières valeurs non-nulles)
        exemples = serie.dropna().head(3).tolist()
        
        rapport_data.append({
            'Colonne':          colonne,
            'Type':             str(serie.dtype),
            'Non-nuls':         n_lignes - n_manquants,
            'Nuls':             n_manquants,
            '% Nuls':           round(pct_manquants, 2),
            'Uniques':          n_uniques,
            'Exemples':         str(exemples)[:60]  # Tronquer à 60 caractères
        })
    
    df_rapport = pd.DataFrame(rapport_data)
    
    # Trier par % de valeurs nulles (décroissant) pour voir les problèmes en premier
    df_rapport = df_rapport.sort_values('% Nuls', ascending=False)
    
    # Afficher le résumé
    print(f"\n  Colonnes avec des NaN :")
    avec_nan = df_rapport[df_rapport['Nuls'] > 0]
    if len(avec_nan) == 0:
        print("  -> Aucune ! Dataset complet [OK]")
    else:
        for _, row in avec_nan.iterrows():
            print(f"  -> {row['Colonne']:40s} : {row['Nuls']:>6,} NaN ({row['% Nuls']:.1f}%)")
    
    print(f"\n  Doublons exacts : {df.duplicated().sum():,}")
    
    return df_rapport


# ─────────────────────────────────────────────────────────────
# FONCTION : LOGGER DES MESSAGES HORODATÉS
# ─────────────────────────────────────────────────────────────

def log(message: str, niveau: str = "INFO") -> None:
    """
    Affiche un message de log avec horodatage et niveau.
    
    Args:
        message: Le message à afficher
        niveau : "INFO", "WARNING", "ERROR", "SUCCESS"
    
    Example:
        >>> log("Chargement du dataset en cours...", "INFO")
        [2024-01-15 10:30:45] [INFO   ] Chargement du dataset en cours...
    """
    # datetime.now() -> date et heure actuelles
    # strftime -> formater en chaîne de caractères
    # "%Y-%m-%d %H:%M:%S" -> format ISO : 2024-01-15 10:30:45
    horodatage = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    
    # Icônes par niveau pour la lisibilité visuelle
    icones = {
        "INFO":    "ℹ ",
        "WARNING": "[ATTENTION] ",
        "ERROR":   "[X]",
        "SUCCESS": "[OK]"
    }
    
    icone = icones.get(niveau, "•")
    
    # Afficher le message formaté
    # f-string avec alignement : {niveau:<8} -> 8 caractères, aligné à gauche
    print(f"[{horodatage}] [{niveau:<8}] {icone} {message}")


# ─────────────────────────────────────────────────────────────
# FONCTION : VÉRIFIER QU'UN FICHIER EXISTE
# ─────────────────────────────────────────────────────────────

def verifier_fichier(chemin: str | Path) -> Path:
    """
    Vérifie qu'un fichier existe et retourne son chemin absolu.
    
    Args:
        chemin: Chemin du fichier (str ou Path)
    
    Returns:
        Objet Path absolu du fichier
    
    Raises:
        FileNotFoundError: Si le fichier n'existe pas
        IsADirectoryError: Si le chemin est un dossier
    
    Example:
        >>> p = verifier_fichier("data/raw/orders.csv")
        >>> print(p.stat().st_size)  # Taille en octets
    """
    p = Path(chemin)  # Convertir en objet Path (fonctionne sur Windows, Mac, Linux)
    
    if not p.exists():
        # FileNotFoundError -> erreur standard Python pour fichier manquant
        raise FileNotFoundError(
            f"Fichier introuvable : {p.absolute()}\n"
            f"Vérifiez que vous avez téléchargé le dataset Olist depuis :\n"
            f"https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce"
        )
    
    if p.is_dir():
        # Le chemin existe mais c'est un dossier, pas un fichier
        raise IsADirectoryError(f"'{chemin}' est un dossier, pas un fichier.")
    
    return p  # Retourner le Path validé


# ─────────────────────────────────────────────────────────────
# POINT D'ENTRÉE : TEST DES UTILITAIRES
# ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    # Ce bloc s'exécute UNIQUEMENT si on lance ce fichier directement
    # (pas quand il est importé dans un autre fichier)
    
    print("=== TEST DES UTILITAIRES ===\n")
    
    # Test 1 : Création de la structure
    creer_structure_projet()
    
    # Test 2 : Formatage des nombres
    print(f"\nFormatage : {formater_nombre(1234567.891)}")
    print(f"Devise    : {formater_nombre(1234567.891, devise=True)}")
    
    # Test 3 : Logger
    log("Démarrage de DataInsight Pro", "SUCCESS")
    log("Fichier manquant détecté", "WARNING")
    
    # Test 4 : Décorateur timeit
    @timeit
    def operation_longue():
        """Simule une opération lente."""
        import time
        time.sleep(0.1)
        return "résultat"
    
    resultat = operation_longue()
    print(f"Résultat : {resultat}")
```

---

## 6.3 — src/data_loader.py (Chargement des Données)

```python
# ============================================================
# src/data_loader.py
# DataInsight Pro — Chargement et Fusion des Données
# ============================================================
# Ce fichier est responsable UNIQUEMENT du chargement des CSV
# et de leur fusion en un DataFrame maître.
# Principe : Single Responsibility (chargement ≠ nettoyage)
# ============================================================

import pandas as pd             # Manipulation de tableaux de données
import numpy as np              # Calcul numérique
from pathlib import Path        # Gestion des chemins de fichiers
from typing import Optional     # Annotations de type (Optional = peut être None)
import warnings                 # Gestion des avertissements Python

# Import de nos utilitaires maison
from utils import (
    DATA_RAW_DIR,      # Chemin vers data/raw/
    DATA_PROCESSED_DIR, # Chemin vers data/processed/
    timeit,            # Décorateur pour mesurer le temps
    log,               # Fonction de logging
    rapport_qualite    # Rapport de qualité des données
)

# Ignorer les avertissements Pandas sur les types mixtes (données réelles)
warnings.filterwarnings('ignore', category=pd.errors.DtypeWarning)


# ─────────────────────────────────────────────────────────────
# DICTIONNAIRE DES FICHIERS CSV AVEC LEURS TYPES
# ─────────────────────────────────────────────────────────────

# Ce dictionnaire mappe chaque fichier CSV avec ses colonnes de dates.
# pd.read_csv avec parse_dates convertira automatiquement ces colonnes.
FICHIERS_OLIST = {
    "orders": {
        "fichier": "olist_orders_dataset.csv",
        "dates":   ["order_purchase_timestamp",
                    "order_approved_at",
                    "order_delivered_carrier_date",
                    "order_delivered_customer_date",
                    "order_estimated_delivery_date"]
    },
    "items": {
        "fichier": "olist_order_items_dataset.csv",
        "dates":   ["shipping_limit_date"]
    },
    "customers": {
        "fichier": "olist_customers_dataset.csv",
        "dates":   []
    },
    "products": {
        "fichier": "olist_products_dataset.csv",
        "dates":   []
    },
    "sellers": {
        "fichier": "olist_sellers_dataset.csv",
        "dates":   []
    },
    "payments": {
        "fichier": "olist_order_payments_dataset.csv",
        "dates":   []
    },
    "reviews": {
        "fichier": "olist_order_reviews_dataset.csv",
        "dates":   ["review_creation_date", "review_answer_timestamp"]
    },
    "translations": {
        "fichier": "product_category_name_translation.csv",
        "dates":   []
    }
}


# ─────────────────────────────────────────────────────────────
# CLASSE PRINCIPALE : OlistDataLoader
# ─────────────────────────────────────────────────────────────

class OlistDataLoader:
    """
    Chargeur de données pour le dataset Olist Brazilian E-Commerce.
    
    Cette classe encapsule toute la logique de chargement et de fusion
    des 9 fichiers CSV du dataset Olist.
    
    Attributes:
        data_dir (Path): Répertoire contenant les fichiers CSV
        dataframes (dict): Dictionnaire des DataFrames chargés
    
    Usage:
        loader = OlistDataLoader()
        loader.charger_tous()
        df_master = loader.creer_dataset_maitre()
    """
    
    def __init__(self, data_dir: Optional[Path] = None):
        """
        Initialise le loader avec le répertoire des données.
        
        Args:
            data_dir: Chemin vers les données brutes.
                     None -> utilise DATA_RAW_DIR depuis utils.py
        """
        # Si aucun chemin fourni, utiliser la constante de utils.py
        self.data_dir = data_dir or DATA_RAW_DIR
        
        # Dictionnaire pour stocker les DataFrames chargés
        # Initialement vide, rempli par charger_tous()
        self.dataframes = {}
        
        log(f"OlistDataLoader initialisé — Répertoire : {self.data_dir}", "INFO")
    
    
    def charger_csv(
        self,
        nom_fichier: str,
        colonnes_dates: list = None,
        nrows: Optional[int] = None
    ) -> pd.DataFrame:
        """
        Charge un fichier CSV avec gestion d'erreurs robuste.
        
        Cette fonction générique peut charger n'importe quel CSV du dataset.
        Elle gère :
        - La conversion des dates (parse_dates)
        - Le chargement partiel (nrows)
        - Les encodages problématiques
        - Les erreurs de fichier manquant
        
        Args:
            nom_fichier    : Nom du fichier (ex: "olist_orders_dataset.csv")
            colonnes_dates : Liste des colonnes à convertir en datetime
            nrows          : Nombre de lignes à charger (None = tout)
        
        Returns:
            DataFrame chargé et prêt à l'emploi
        
        Raises:
            FileNotFoundError: Si le fichier CSV n'existe pas
        """
        # Construire le chemin complet du fichier
        # self.data_dir / nom_fichier -> Path("data/raw/olist_orders_dataset.csv")
        chemin = self.data_dir / nom_fichier
        
        # Vérifier que le fichier existe avant d'essayer de le lire
        if not chemin.exists():
            raise FileNotFoundError(
                f"\n[X] Fichier introuvable : {chemin}"
                f"\n   Avez-vous téléchargé le dataset Olist ?"
                f"\n   -> https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce"
                f"\n   -> Placez les CSV dans : {self.data_dir}"
            )
        
        log(f"Chargement de {nom_fichier}...", "INFO")
        
        # Lire le CSV avec pandas
        # parse_dates -> convertit automatiquement les colonnes de dates
        # low_memory=False -> évite l'avertissement sur les types mixtes
        # nrows -> charge seulement les N premières lignes (utile pour tester)
        df = pd.read_csv(
            chemin,
            parse_dates=colonnes_dates or [],
            low_memory=False,
            nrows=nrows,
            encoding='utf-8'  # Encodage explicite -> évite les surprises
        )
        
        # Afficher les informations de chargement
        taille_mb = chemin.stat().st_size / 1024**2  # Taille en MB
        log(f"  -> {len(df):,} lignes × {len(df.columns)} colonnes "
            f"| {taille_mb:.1f} MB", "SUCCESS")
        
        return df
    
    
    @timeit  # Décorateur -> affiche le temps total de chargement
    def charger_tous(self, nrows: Optional[int] = None) -> dict:
        """
        Charge TOUS les fichiers CSV du dataset Olist.
        
        Parcourt le dictionnaire FICHIERS_OLIST et charge chaque fichier.
        Les DataFrames sont stockés dans self.dataframes.
        
        Args:
            nrows: Si défini, charge seulement N lignes de chaque fichier.
                  Utile pour tester avec un sous-ensemble de données.
        
        Returns:
            Dictionnaire {nom: DataFrame} de tous les fichiers chargés
        
        Example:
            loader = OlistDataLoader()
            dfs = loader.charger_tous(nrows=10000)  # Test rapide
            # ou
            dfs = loader.charger_tous()  # Tout charger
        """
        log("Démarrage du chargement de tous les fichiers Olist...", "INFO")
        
        # Parcourir le dictionnaire de configuration
        # .items() -> retourne des tuples (clé, valeur)
        for nom, config in FICHIERS_OLIST.items():
            try:
                # try/except -> gérer les erreurs sans arrêter le programme
                df = self.charger_csv(
                    nom_fichier=config["fichier"],
                    colonnes_dates=config["dates"],
                    nrows=nrows
                )
                # Stocker dans le dictionnaire avec le nom court
                # Ex: self.dataframes["orders"] = df_orders
                self.dataframes[nom] = df
                
            except FileNotFoundError as e:
                # FileNotFoundError -> fichier CSV manquant
                log(f"Fichier manquant pour '{nom}' : {e}", "WARNING")
                # On continue avec les autres fichiers (pas d'arrêt brutal)
        
        log(f"Chargement terminé : {len(self.dataframes)}/{len(FICHIERS_OLIST)} fichiers", "SUCCESS")
        return self.dataframes
    
    
    def valider_integrite(self) -> bool:
        """
        Vérifie l'intégrité référentielle entre les tables.
        
        Vérifie que :
        - Les order_id dans items existent dans orders
        - Les customer_id dans orders existent dans customers
        - Les product_id dans items existent dans products
        
        Returns:
            True si intégrité OK, False sinon
        """
        print("\n" + "─"*60)
        print("  VÉRIFICATION DE L'INTÉGRITÉ RÉFÉRENTIELLE")
        print("─"*60)
        
        erreurs = 0
        
        # Vérification 1 : order_id items ⊆ order_id orders
        if "orders" in self.dataframes and "items" in self.dataframes:
            orders_ids = set(self.dataframes["orders"]["order_id"])
            items_ids  = set(self.dataframes["items"]["order_id"])
            
            orphelins = items_ids - orders_ids  # Dans items mais PAS dans orders
            if orphelins:
                log(f"Items orphelins (sans order) : {len(orphelins):,}", "WARNING")
                erreurs += 1
            else:
                log("[OK] items.order_id ⊆ orders.order_id", "SUCCESS")
        
        # Vérification 2 : customer_id orders ⊆ customer_id customers
        if "orders" in self.dataframes and "customers" in self.dataframes:
            cust_ids_orders    = set(self.dataframes["orders"]["customer_id"])
            cust_ids_customers = set(self.dataframes["customers"]["customer_id"])
            
            orphelins = cust_ids_orders - cust_ids_customers
            if orphelins:
                log(f"Commandes sans client : {len(orphelins):,}", "WARNING")
                erreurs += 1
            else:
                log("[OK] orders.customer_id ⊆ customers.customer_id", "SUCCESS")
        
        # Vérification 3 : product_id items ⊆ product_id products
        if "items" in self.dataframes and "products" in self.dataframes:
            prod_ids_items    = set(self.dataframes["items"]["product_id"])
            prod_ids_products = set(self.dataframes["products"]["product_id"])
            
            orphelins = prod_ids_items - prod_ids_products
            if orphelins:
                log(f"Items avec produit inexistant : {len(orphelins):,}", "WARNING")
                erreurs += 1
            else:
                log("[OK] items.product_id ⊆ products.product_id", "SUCCESS")
        
        integrite_ok = (erreurs == 0)
        print(f"\n  Résultat : {'[OK] Intégrité OK' if integrite_ok else f'[ATTENTION]  {erreurs} problème(s) détecté(s)'}")
        return integrite_ok
    
    
    @timeit
    def creer_dataset_maitre(self) -> pd.DataFrame:
        """
        Fusionne toutes les tables en un seul DataFrame maître.
        
        Le dataset maître contient :
        - 1 ligne par item de commande
        - Toutes les informations : commande + client + produit + paiement + avis
        
        Stratégie de jointures :
        - orders -> items          : LEFT JOIN sur order_id
        - résultat -> customers    : LEFT JOIN sur customer_id
        - résultat -> products     : LEFT JOIN sur product_id
        - résultat -> payments     : LEFT JOIN sur order_id (agrégé)
        - résultat -> reviews      : LEFT JOIN sur order_id
        - résultat -> translations : LEFT JOIN sur product_category_name
        
        Returns:
            DataFrame maître avec toutes les informations fusionnées
        
        Raises:
            ValueError: Si les tables essentielles n'ont pas été chargées
        """
        # Vérifier que les tables essentielles sont disponibles
        tables_essentielles = ["orders", "items", "customers"]
        for table in tables_essentielles:
            if table not in self.dataframes:
                raise ValueError(
                    f"Table '{table}' non chargée. "
                    f"Appelez charger_tous() avant creer_dataset_maitre()."
                )
        
        log("Construction du dataset maître...", "INFO")
        
        # ─── ÉTAPE 1 : orders + items ──────────────────────────────────
        # LEFT JOIN : garder toutes les commandes, même sans items
        # pd.merge -> équivalent SQL JOIN
        df = pd.merge(
            self.dataframes["orders"],    # Table de gauche (LEFT)
            self.dataframes["items"],     # Table de droite
            on="order_id",               # Clé de jointure commune
            how="left"                   # LEFT JOIN -> garder toutes les lignes de gauche
        )
        log(f"  orders + items : {len(df):,} lignes", "INFO")
        
        # ─── ÉTAPE 2 : + customers ─────────────────────────────────────
        df = pd.merge(
            df,
            self.dataframes["customers"],
            on="customer_id",
            how="left"
        )
        log(f"  + customers : {len(df):,} lignes", "INFO")
        
        # ─── ÉTAPE 3 : + products ──────────────────────────────────────
        if "products" in self.dataframes:
            df = pd.merge(
                df,
                self.dataframes["products"],
                on="product_id",
                how="left"
            )
            log(f"  + products : {len(df):,} lignes", "INFO")
        
        # ─── ÉTAPE 4 : + payments (agrégé par commande) ────────────────
        # Une commande peut avoir plusieurs paiements (carte + bon de réduction)
        # On agrège pour avoir 1 ligne par commande
        if "payments" in self.dataframes:
            # groupby("order_id") -> regrouper par commande
            # agg -> calculer des statistiques par groupe
            payments_agg = (
                self.dataframes["payments"]
                .groupby("order_id")
                .agg(
                    payment_total=("payment_value", "sum"),        # Montant total payé
                    payment_type=("payment_type", "first"),        # Type de paiement principal
                    payment_installments=("payment_installments", "max")  # Nb max mensualités
                )
                .reset_index()  # Remettre order_id comme colonne (pas comme index)
            )
            df = pd.merge(df, payments_agg, on="order_id", how="left")
            log(f"  + payments (agrégés) : {len(df):,} lignes", "INFO")
        
        # ─── ÉTAPE 5 : + reviews ───────────────────────────────────────
        if "reviews" in self.dataframes:
            # Garder seulement les colonnes utiles des avis
            reviews_slim = self.dataframes["reviews"][[
                "order_id", "review_score", "review_comment_message"
            ]].copy()
            
            # Si plusieurs avis pour une commande, garder le dernier
            # (basé sur review_creation_date, mais on prend le dernier indice)
            reviews_slim = reviews_slim.drop_duplicates("order_id", keep="last")
            
            df = pd.merge(df, reviews_slim, on="order_id", how="left")
            log(f"  + reviews : {len(df):,} lignes", "INFO")
        
        # ─── ÉTAPE 6 : + traductions des catégories ────────────────────
        if "translations" in self.dataframes:
            df = pd.merge(
                df,
                self.dataframes["translations"],
                on="product_category_name",
                how="left"
            )
            # Renommer pour plus de clarté
            df = df.rename(columns={
                "product_category_name_english": "category_english"
            })
            log(f"  + translations : {len(df):,} lignes", "INFO")
        
        log(f"Dataset maître créé : {df.shape[0]:,} lignes × {df.shape[1]} colonnes", "SUCCESS")
        
        return df
    
    
    def sauvegarder_parquet(self, df: pd.DataFrame, nom: str) -> Path:
        """
        Sauvegarde un DataFrame au format Parquet (plus efficace que CSV).
        
        Pourquoi Parquet plutôt que CSV ?
        - Compression : 5-10× plus petit
        - Vitesse de lecture : 10-100× plus rapide
        - Préserve les types (pas besoin de reconvertir les dates)
        
        Args:
            df  : DataFrame à sauvegarder
            nom : Nom du fichier (sans extension)
        
        Returns:
            Chemin du fichier créé
        """
        # Construire le chemin de sortie
        chemin = DATA_PROCESSED_DIR / f"{nom}.parquet"
        
        # to_parquet -> écrire au format Parquet
        # index=False -> ne pas sauvegarder l'index (économise de l'espace)
        # engine='pyarrow' -> utiliser pyarrow pour la sérialisation
        df.to_parquet(chemin, index=False, engine='pyarrow')
        
        taille_mb = chemin.stat().st_size / 1024**2
        log(f"Sauvegardé : {chemin.name} ({taille_mb:.1f} MB)", "SUCCESS")
        
        return chemin


# ─────────────────────────────────────────────────────────────
# POINT D'ENTRÉE : DÉMO DU CHARGEMENT
# ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    """
    Démonstration complète du chargement des données.
    Exécutez avec : python src/data_loader.py
    """
    
    print("="*65)
    print("  DataInsight Pro — CHARGEMENT DES DONNÉES OLIST")
    print("="*65)
    
    # Créer le loader
    loader = OlistDataLoader()
    
    # Charger tous les fichiers
    # nrows=50000 pour un test rapide (enlever pour charger tout)
    dfs = loader.charger_tous(nrows=50000)
    
    # Afficher un résumé de chaque fichier chargé
    print("\n--- Résumé des fichiers chargés ---")
    for nom, df in dfs.items():
        print(f"  {nom:15s} : {df.shape[0]:>8,} lignes × {df.shape[1]:>2} colonnes")
    
    # Vérifier l'intégrité des jointures
    loader.valider_integrite()
    
    # Générer un rapport de qualité sur les commandes
    if "orders" in dfs:
        rapport = rapport_qualite(dfs["orders"], "Commandes (Orders)")
        print("\nRapport de qualité (top 5 colonnes avec NaN) :")
        print(rapport.head())
    
    # Créer et sauvegarder le dataset maître
    df_master = loader.creer_dataset_maitre()
    chemin_sauvegarde = loader.sauvegarder_parquet(df_master, "master_dataset")
    
    print(f"\n[OK] Dataset maître sauvegardé : {chemin_sauvegarde}")
    print(f"   Dimensions : {df_master.shape[0]:,} lignes × {df_master.shape[1]} colonnes")
    print(f"\n   Colonnes disponibles :")
    for col in df_master.columns:
        print(f"   -> {col}")
```

---

## 6.4 — main.py (Point d'Entrée)

```python
# ============================================================
# main.py
# DataInsight Pro — Point d'Entrée Principal
# ============================================================
# Ce fichier orchestre l'exécution complète du pipeline.
# Exécution : python main.py
# ============================================================

import sys                      # Accès aux arguments de ligne de commande
from pathlib import Path        # Gestion des chemins
import argparse                 # Parsing des arguments en ligne de commande

# Ajouter src/ au chemin Python pour les imports
# sys.path -> liste des répertoires où Python cherche les modules
sys.path.insert(0, str(Path(__file__).parent / "src"))

# Maintenant on peut importer nos modules
from utils import log, creer_structure_projet
from data_loader import OlistDataLoader


def main():
    """
    Fonction principale du pipeline DataInsight Pro.
    
    Orchestre toutes les étapes dans l'ordre :
    1. Initialisation (dossiers, logs)
    2. Chargement des données
    3. Nettoyage
    4. Analyse
    5. Visualisation
    6. Rapport
    """
    
    # ─── Parsing des arguments ────────────────────────────────────────
    # argparse -> permet de passer des options en ligne de commande
    # Exemple : python main.py --nrows 10000 --etape chargement
    
    parser = argparse.ArgumentParser(
        description="DataInsight Pro — Pipeline d'analyse e-commerce Olist",
        formatter_class=argparse.RawDescriptionHelpFormatter
    )
    
    parser.add_argument(
        "--nrows",
        type=int,
        default=None,
        help="Nombre de lignes à charger par fichier (None = tout)"
    )
    
    parser.add_argument(
        "--etape",
        choices=["tout", "chargement", "nettoyage", "analyse", "visualisation"],
        default="tout",
        help="Quelle étape du pipeline exécuter"
    )
    
    args = parser.parse_args()
    
    # ─── Démarrage ────────────────────────────────────────────────────
    print("\n" + "█"*65)
    print("█" + " "*23 + "DataInsight Pro" + " "*24 + "█")
    print("█" + " "*15 + "Analyse E-Commerce Olist" + " "*22 + "█")
    print("█"*65 + "\n")
    
    log("Démarrage du pipeline DataInsight Pro", "SUCCESS")
    
    # ─── ÉTAPE 0 : Initialisation ─────────────────────────────────────
    creer_structure_projet()
    
    # ─── ÉTAPE 1 : Chargement ─────────────────────────────────────────
    if args.etape in ["tout", "chargement"]:
        log("ÉTAPE 1 : Chargement des données", "INFO")
        
        loader = OlistDataLoader()
        dfs = loader.charger_tous(nrows=args.nrows)
        
        if len(dfs) == 0:
            log("Aucune donnée chargée. Vérifiez le répertoire data/raw/", "ERROR")
            sys.exit(1)  # Code de sortie 1 = erreur
        
        loader.valider_integrite()
        df_master = loader.creer_dataset_maitre()
        loader.sauvegarder_parquet(df_master, "master_dataset")
        
        log(f"Dataset maître : {df_master.shape[0]:,} × {df_master.shape[1]}", "SUCCESS")
    
    # ─── ÉTAPES SUIVANTES (parties 2 à 8) ─────────────────────────────
    if args.etape in ["tout", "nettoyage"]:
        log("ÉTAPE 2 : Nettoyage — Voir Partie 2 du projet", "INFO")
    
    if args.etape in ["tout", "analyse"]:
        log("ÉTAPE 3 : Analyse EDA — Voir Partie 4 du projet", "INFO")
    
    if args.etape in ["tout", "visualisation"]:
        log("ÉTAPE 4 : Visualisation — Voir Partie 5 du projet", "INFO")
    
    log("Pipeline terminé avec succès !", "SUCCESS")


if __name__ == "__main__":
    # Ce bloc s'exécute seulement si main.py est lancé directement
    # (pas si importé comme module)
    main()
```

---

# 7⃣ ANALYSE ET INTERPRÉTATION

## Résultats Attendus Après Chargement

Après avoir exécuté le code ci-dessus, vous devriez obtenir :

```
=== RÉSUMÉ DES FICHIERS CHARGÉS ===
  orders          :   99,441 lignes × 8  colonnes
  items           :  112,650 lignes × 7  colonnes
  customers       :   99,441 lignes × 5  colonnes
  products        :   32,951 lignes × 9  colonnes
  sellers         :    3,095 lignes × 4  colonnes
  payments        :  103,886 lignes × 5  colonnes
  reviews         :   99,224 lignes × 7  colonnes
  translations    :       71 lignes × 2  colonnes
```

## Insights Préliminaires à Retenir

1. **112,650 items vs 99,441 commandes** -> Certaines commandes ont plusieurs produits
2. **99,224 avis vs 99,441 commandes** -> 99.8% des commandes ont un avis (excellent taux)
3. **103,886 paiements vs 99,441 commandes** -> Certaines commandes utilisent plusieurs moyens de paiement
4. **32,951 produits pour 3,095 vendeurs** -> En moyenne ~10 produits par vendeur

---

# 8⃣ BONNES PRATIQUES APPLIQUÉES

## [OK] Règles Professionnelles Suivies

| Règle | Application dans le Code |
|-------|--------------------------|
| **Single Responsibility** | data_loader.py ne fait QUE charger, pas nettoyer |
| **Don't Repeat Yourself** | FICHIERS_OLIST évite de répéter les chemins |
| **Type Hints** | `def charger_csv(...) -> pd.DataFrame:` |
| **Docstrings** | Chaque fonction documentée avec Args, Returns, Example |
| **Gestion d'erreurs** | try/except + messages d'erreur explicites |
| **Constantes** | DATA_RAW_DIR centralisé dans utils.py |
| **Copie défensive** | df.copy() avant transformation |
| **Formatage** | f-strings modernes, pas de concatenation + |

---

# 9⃣ ERREURS FRÉQUENTES

## [X] Erreur 1 : Modifier les données brutes

```python
# MAUVAIS : modifier directement les données brutes
df_orders["prix"] = df_orders["prix"].fillna(0)  # <- DANGER !

# BON : toujours travailler sur une copie
df_orders_clean = df_orders.copy()
df_orders_clean["prix"] = df_orders_clean["prix"].fillna(0)
```

## [X] Erreur 2 : Oublier parse_dates

```python
# MAUVAIS : date chargée comme string
df = pd.read_csv("orders.csv")
# orders["order_purchase_timestamp"].dtype -> object (chaîne de caractères)
# Impossible de calculer la durée de livraison !

# BON : conversion automatique à la lecture
df = pd.read_csv("orders.csv",
                 parse_dates=["order_purchase_timestamp"])
# dtype -> datetime64[ns]
# Calcul possible : délai = livraison - achat
```

## [X] Erreur 3 : Ne pas vérifier l'existence des fichiers

```python
# MAUVAIS : crash si le fichier manque
df = pd.read_csv("orders.csv")  # FileNotFoundError sans message clair

# BON : message d'erreur utile avec instructions
if not Path("orders.csv").exists():
    raise FileNotFoundError(
        "Téléchargez d'abord le dataset depuis Kaggle : ..."
    )
```

## [X] Erreur 4 : Mauvais type de jointure

```python
# MAUVAIS : INNER JOIN supprime les commandes sans items
df = pd.merge(orders, items, on="order_id", how="inner")

# BON : LEFT JOIN garde toutes les commandes
df = pd.merge(orders, items, on="order_id", how="left")
```

---

# [10] EXERCICES

## [VERT] NIVEAU FACILE

**Exercice 1 :** Clonez la structure du projet sur votre machine.
Créez les dossiers avec Python (utilisez `Path.mkdir()`).
Vérifiez avec `Path.exists()` que tout est en place.

**Exercice 2 :** Téléchargez le dataset Olist sur Kaggle.
Placez les CSV dans `data/raw/`.
Chargez uniquement `olist_orders_dataset.csv` et affichez :
- Le nombre de lignes
- Les 5 premières lignes (`df.head()`)
- Les types de données (`df.dtypes`)

**Exercice 3 :** Combien de statuts de commande différents (`order_status`) existent ?
Quel est le statut le plus fréquent ? Calculez les proportions.

## [JAUNE] NIVEAU INTERMÉDIAIRE

**Exercice 4 :** Modifiez `OlistDataLoader.charger_tous()` pour afficher une barre de progression (utilisez `tqdm`).

**Exercice 5 :** Ajoutez une méthode `OlistDataLoader.resumer()` qui affiche un tableau récapitulatif de toutes les tables chargées (nom, lignes, colonnes, taille mémoire).

**Exercice 6 :** Créez une fonction `detecter_doublons_dans_toutes_tables()` qui parcourt toutes les tables et affiche le nombre de doublons dans chacune.

## [ROUGE] NIVEAU AVANCÉ

**Exercice 7 :** Implémentez un système de cache : si `master_dataset.parquet` existe déjà et est plus récent que les CSV, charger le Parquet directement (éviter de tout recalculer). Utiliser `os.path.getmtime()`.

**Exercice 8 :** Ajoutez un mode `--mode validation` à `main.py` qui vérifie que toutes les contraintes d'intégrité sont respectées et génère un rapport de validation en JSON.

---

# 1⃣1⃣ CORRIGÉ ULTRA DÉTAILLÉ

## Corrigé Exercice 3 — Statuts de Commandes

```python
import pandas as pd
from pathlib import Path

# ─── Chargement ──────────────────────────────────────────────
# Path("data/raw") -> objet Path représentant le dossier
# / "olist_orders_dataset.csv" -> opérateur / de pathlib pour joindre
chemin = Path("data/raw") / "olist_orders_dataset.csv"

# read_csv -> lire un fichier CSV en DataFrame
df_orders = pd.read_csv(chemin)

print(f"Dataset chargé : {df_orders.shape[0]:,} lignes")

# ─── Nombre de statuts différents ────────────────────────────
# nunique() -> NUmber UNIque values (nombre de valeurs distinctes)
n_statuts = df_orders["order_status"].nunique()
print(f"\nNombre de statuts différents : {n_statuts}")

# ─── Valeurs et fréquences ───────────────────────────────────
# value_counts() -> compte les occurrences de chaque valeur
# normalize=True -> retourne les proportions (0.0 à 1.0) au lieu des comptes
vc = df_orders["order_status"].value_counts()
vc_pct = df_orders["order_status"].value_counts(normalize=True) * 100

# ─── Tableau de synthèse ─────────────────────────────────────
tableau = pd.DataFrame({
    "Effectif": vc,
    "Pourcentage (%)": vc_pct.round(2)
})
# .reset_index() -> remettre order_status comme colonne
# (par défaut il devient l'index)
tableau = tableau.reset_index()
tableau.columns = ["Statut", "Effectif", "Pourcentage (%)"]

print("\nDistribution des statuts de commande :")
print(tableau.to_string(index=False))

# ─── Résultat attendu ─────────────────────────────────────────
# Statut         Effectif  Pourcentage (%)
# delivered        96478           97.02
# shipped           1107            1.11
# canceled           625            0.63
# unavailable        609            0.61
# processing         301            0.30
# invoiced           314            0.32
# created              5            0.01
# approved             2            0.00

# ─── Insight métier ──────────────────────────────────────────
print(f"\n[OK] Insight : {vc_pct['delivered']:.1f}% des commandes sont livrées")
print(f"[ATTENTION]  {vc_pct.get('canceled', 0):.2f}% sont annulées")
print(f"   Statut dominant : '{vc.index[0]}' ({vc.iloc[0]:,} commandes)")
```

## Corrigé Exercice 7 — Système de Cache

```python
import os
import pandas as pd
from pathlib import Path
from datetime import datetime

def charger_avec_cache(
    dossier_raw: Path,
    fichier_cache: Path,
    loader_fn,  # Fonction qui charge et fusionne les CSV
) -> pd.DataFrame:
    """
    Charge les données depuis le cache Parquet si disponible et à jour,
    sinon recrée le cache depuis les CSV.
    
    Args:
        dossier_raw   : Dossier contenant les CSV bruts
        fichier_cache : Chemin du fichier Parquet de cache
        loader_fn     : Fonction qui crée le DataFrame depuis les CSV
    
    Returns:
        DataFrame (depuis cache ou recalculé)
    """
    
    # Vérifier si le cache existe
    if fichier_cache.exists():
        
        # Trouver la date de modification la plus récente parmi les CSV
        csv_files = list(dossier_raw.glob("*.csv"))
        
        if csv_files:
            # max() sur les dates de modification
            # os.path.getmtime() -> timestamp Unix (secondes depuis 1970)
            dernier_csv_mtime = max(
                os.path.getmtime(f) for f in csv_files
            )
            
            # Date de modification du cache Parquet
            cache_mtime = os.path.getmtime(fichier_cache)
            
            # Si le cache est plus récent que tous les CSV -> utiliser le cache
            if cache_mtime > dernier_csv_mtime:
                date_cache = datetime.fromtimestamp(cache_mtime)
                print(f"[PACKAGE] Cache valide (mis à jour le {date_cache:%Y-%m-%d %H:%M})")
                print(f"   Chargement depuis : {fichier_cache.name}")
                
                # pd.read_parquet -> beaucoup plus rapide que read_csv !
                return pd.read_parquet(fichier_cache)
            else:
                print("[ATTENTION]  CSV plus récents que le cache -> Recalcul nécessaire")
        else:
            print("[ATTENTION]  Aucun CSV trouvé dans data/raw/")
    else:
        print("[LISTE] Cache inexistant -> Création en cours...")
    
    # Cache invalide ou inexistant : recalculer
    df = loader_fn()  # Appel de la fonction de chargement
    
    # Sauvegarder le nouveau cache
    fichier_cache.parent.mkdir(parents=True, exist_ok=True)
    df.to_parquet(fichier_cache, index=False)
    print(f"[OK] Cache créé : {fichier_cache}")
    
    return df


# ─── Utilisation ─────────────────────────────────────────────
# from data_loader import OlistDataLoader
# loader = OlistDataLoader()
# loader.charger_tous()

# df_master = charger_avec_cache(
#     dossier_raw  = Path("data/raw"),
#     fichier_cache= Path("data/processed/master_dataset.parquet"),
#     loader_fn    = loader.creer_dataset_maitre
# )
```

---

# [IMPORTANT] RÉCAPITULATIF DE LA PARTIE 1

| Concept | Ce que vous avez appris |
|---------|------------------------|
| Structure de projet | Architecture maintenable avec séparation des responsabilités |
| Dataset réel | Olist Brazilian E-Commerce (100k+ lignes, 9 tables) |
| Pathlib | Gestion cross-plateforme des chemins de fichiers |
| pd.read_csv | Chargement avec parse_dates, encoding, nrows |
| pd.merge | LEFT JOIN entre tables (équivalent SQL) |
| Décorateurs | @timeit pour mesurer les performances |
| Classes Python | OlistDataLoader avec __init__, méthodes, attributs |
| Parquet | Format de stockage plus efficace que CSV |
| Type hints | Annotations -> pd.DataFrame pour la documentation |
| Gestion d'erreurs | try/except + FileNotFoundError explicites |

---

# [LIEN] RESSOURCES UTILES

- **Dataset Olist :** https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce
- **Documentation Pandas read_csv :** https://pandas.pydata.org/docs/reference/api/pandas.read_csv.html
- **Documentation Pathlib :** https://docs.python.org/3/library/pathlib.html
- **Pandas merge/join :** https://pandas.pydata.org/docs/user_guide/merging.html

---

*DataInsight Pro — Partie 1 | Prochaine étape -> Partie 2 : Nettoyage Complet des Données*

# [GRAPHIQUE] DataInsight Pro — PARTIE 2
## Chargement Avancé & Nettoyage Professionnel des Données

---

# 1⃣ CONTEXTE MÉTIER

## La Mission du Data Scientist : Garantir la Qualité

Après le chargement réussi du dataset Olist, votre responsable vous demande :

> "Avant de commencer l'analyse, je veux être sûr que nos données sont fiables. On a eu des problèmes par le passé avec des valeurs aberrantes qui faussaient nos KPIs. Nettoyez le dataset et produisez un rapport de qualité."

### Problèmes Identifiés dans le Dataset Olist (Réels)

1. **Dates manquantes** : `order_delivered_customer_date` est null pour les commandes non livrées
2. **Catégories de produits** : Certains noms sont en portugais, d'autres traduits
3. **Prix aberrants** : Quelques items à 0 BRL ou valeurs extrêmes
4. **Commandes annulées** : Doivent être traitées différemment des commandes livrées
5. **Doublons potentiels** : Suite à des exports multiples
6. **Types incorrects** : Colonnes numériques parfois stockées en string

---

# 2⃣ OBJECTIFS PÉDAGOGIQUES

[OK] Maîtriser la détection et le traitement des valeurs manquantes (NaN)  
[OK] Détecter et supprimer les doublons proprement  
[OK] Identifier et traiter les outliers (Z-score, IQR, règles métier)  
[OK] Corriger les types de données incorrects  
[OK] Créer de nouvelles variables (feature engineering)  
[OK] Valider les données avec des règles métier  
[OK] Produire un rapport de nettoyage documenté  

**Concepts :** fillna, dropna, drop_duplicates, astype, pd.to_datetime, clip, apply, np.where, pd.cut

---

# 3⃣ ÉNONCÉ DÉTAILLÉ

### Tâches à Réaliser

**Tâche 1 :** Audit complet des données manquantes (heatmap + rapport)  
**Tâche 2 :** Stratégie de traitement par colonne (supprimer vs imputer)  
**Tâche 3 :** Détection des doublons (exacts et partiels)  
**Tâche 4 :** Détection des outliers dans les prix et quantités  
**Tâche 5 :** Feature engineering (délai de livraison, retard, etc.)  
**Tâche 6 :** Validation des règles métier (prix > 0, dates cohérentes)  
**Tâche 7 :** Sauvegarde du dataset propre en Parquet  

---

# 4⃣ THÉORIE APPLIQUÉE

## Les 3 Mécanismes de Données Manquantes

**MCAR** (Missing Completely At Random) : La donnée manque de façon aléatoire.  
-> Exemple Olist : `review_comment_title` absent car optionnel -> pas de biais  
-> Traitement : suppression ou imputation simple acceptable

**MAR** (Missing At Random) : La manquance dépend d'autres variables observées.  
-> Exemple Olist : `order_delivered_customer_date` absent car commande annulée -> lié à `order_status`  
-> Traitement : imputer selon le groupe (commandes annulées ≠ livrées)

**MNAR** (Missing Not At Random) : La manquance dépend de la valeur elle-même.  
-> Exemple Olist : `review_comment_message` absent -> peut-être que les clients très insatisfaits ne commentent pas  
-> Traitement : créer un indicateur de manquance + analyse séparée

## Règle des Seuils de Tolérance

| % NaN | Stratégie Recommandée |
|-------|----------------------|
| < 5%  | N'importe quelle stratégie (suppression ou imputation) |
| 5-20% | Imputation (médiane, mode, KNN) |
| 20-50%| Imputation avancée + créer indicateur binaire |
| > 50% | Envisager de supprimer la colonne |

---

# 5⃣ IMPLÉMENTATION COMPLÈTE

## src/data_cleaning.py

```python
# ============================================================
# src/data_cleaning.py
# DataInsight Pro — Nettoyage et Validation des Données
# ============================================================
# Ce module transforme le dataset brut en dataset exploitable.
# Principe fondamental :
#   - NE JAMAIS modifier les données brutes (data/raw/)
#   - Toujours travailler sur des copies
#   - Documenter chaque transformation
# ============================================================

import pandas as pd              # Manipulation de données
import numpy as np               # Calcul numérique
import matplotlib.pyplot as plt  # Visualisation
import seaborn as sns            # Visualisation statistique
from pathlib import Path         # Gestion des chemins
from datetime import datetime    # Manipulation des dates
from typing import List, Dict, Tuple, Optional  # Type hints

# Import de nos utilitaires maison
import sys
sys.path.insert(0, str(Path(__file__).parent))
from utils import log, timeit, formater_nombre, FIGURES_DIR


# ─────────────────────────────────────────────────────────────
# CLASSE PRINCIPALE : OlistDataCleaner
# ─────────────────────────────────────────────────────────────

class OlistDataCleaner:
    """
    Nettoie et transforme le dataset maître Olist.
    
    Cette classe applique toutes les transformations nécessaires
    pour passer des données brutes aux données exploitables pour l'EDA.
    
    Chaque méthode de nettoyage :
    1. Reçoit un DataFrame (ou utilise self.df)
    2. Applique des transformations documentées
    3. Retourne le DataFrame transformé
    4. NE MODIFIE PAS les données originales
    
    Attributes:
        df_brut (pd.DataFrame): Données originales (JAMAIS modifiées)
        df (pd.DataFrame): Copie de travail (transformée)
        journal (list): Journal des transformations effectuées
    """
    
    def __init__(self, df_brut: pd.DataFrame):
        """
        Initialise le cleaner avec le dataset maître.
        
        Args:
            df_brut: Le DataFrame brut issu de OlistDataLoader
        """
        # Stocker une référence au dataset brut (NE PAS modifier)
        self.df_brut = df_brut
        
        # Créer une copie de travail indépendante
        # .copy() -> copie profonde : toute modification sur self.df
        # n'affecte PAS self.df_brut
        self.df = df_brut.copy()
        
        # Journal de toutes les transformations pour la traçabilité
        self.journal = []
        
        log(f"OlistDataCleaner initialisé : {self.df.shape[0]:,} lignes", "INFO")
    
    
    def _journaliser(self, operation: str, avant: int, apres: int, detail: str = "") -> None:
        """
        Enregistre une opération de nettoyage dans le journal.
        
        Args:
            operation : Nom de l'opération (ex: "Suppression NaN")
            avant     : Nombre de lignes avant
            apres     : Nombre de lignes après
            detail    : Information supplémentaire
        """
        entree = {
            "timestamp":  datetime.now().strftime("%H:%M:%S"),
            "operation":  operation,
            "avant":      avant,
            "apres":      apres,
            "supprimees": avant - apres,
            "detail":     detail
        }
        self.journal.append(entree)
        
        if avant != apres:
            log(f"  {operation}: {avant:,} -> {apres:,} lignes "
                f"(-{avant-apres:,}) | {detail}", "INFO")
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 1 : AUDIT DES DONNÉES MANQUANTES
    # ─────────────────────────────────────────────────────────
    
    def auditer_nan(self, afficher_graphique: bool = True) -> pd.DataFrame:
        """
        Génère un rapport complet des valeurs manquantes.
        
        Calcule pour chaque colonne :
        - Nombre absolu de NaN
        - Pourcentage de NaN
        - Type de données
        - Recommandation de traitement
        
        Args:
            afficher_graphique: Si True, génère une heatmap des NaN
        
        Returns:
            DataFrame de rapport avec une ligne par colonne
        """
        print("\n" + "="*65)
        print("  AUDIT DES VALEURS MANQUANTES")
        print("="*65)
        
        n_total = len(self.df)
        
        # ─── Calcul des NaN par colonne ───────────────────────────────
        rapport_data = []
        
        for col in self.df.columns:
            n_nan = self.df[col].isnull().sum()      # Compter les NaN
            pct   = n_nan / n_total * 100             # Calculer le %
            
            # Choisir la recommandation selon le % de NaN
            if pct == 0:
                recommandation = "[OK] Complet"
            elif pct < 5:
                recommandation = "[NOTE] Imputation simple (médiane/mode)"
            elif pct < 20:
                recommandation = "[OUTIL] Imputation avancée (KNN)"
            elif pct < 50:
                recommandation = "[ATTENTION]  Imputation + indicateur"
            else:
                recommandation = "[X] Envisager suppression colonne"
            
            rapport_data.append({
                "Colonne":       col,
                "Type":         str(self.df[col].dtype),
                "NaN":           n_nan,
                "% NaN":         round(pct, 2),
                "Recommandation": recommandation
            })
        
        # Créer le DataFrame de rapport, trié par % NaN décroissant
        df_rapport = (pd.DataFrame(rapport_data)
                      .sort_values("% NaN", ascending=False))
        
        # Afficher seulement les colonnes avec NaN
        avec_nan = df_rapport[df_rapport["NaN"] > 0]
        
        if len(avec_nan) == 0:
            print("  -> Dataset complet ! Aucune valeur manquante. [OK]")
        else:
            print(f"  Colonnes avec NaN ({len(avec_nan)}/{len(self.df.columns)}) :\n")
            print(avec_nan.to_string(index=False))
        
        # ─── Visualisation Heatmap ────────────────────────────────────
        if afficher_graphique and len(avec_nan) > 0:
            
            # Sélectionner seulement les colonnes avec NaN (max 15 pour lisibilité)
            cols_nan = avec_nan["Colonne"].head(15).tolist()
            df_viz   = self.df[cols_nan].copy()
            
            fig, axes = plt.subplots(1, 2, figsize=(16, 6))
            
            # Heatmap des NaN (True = manquant = jaune)
            # isnull() -> matrice booléenne True si NaN, False sinon
            sns.heatmap(
                df_viz.isnull().sample(min(500, len(df_viz)), random_state=42),
                yticklabels=False,  # Cacher les labels des lignes (trop nombreux)
                cbar=False,         # Pas de colorbar (True/False est suffisant)
                cmap="viridis",     # Palette : violet = présent, jaune = manquant
                ax=axes[0]
            )
            axes[0].set_title(
                "Heatmap des valeurs manquantes\n"
                "(Jaune = manquant | Violet = présent)",
                fontsize=11
            )
            axes[0].set_xlabel("Colonnes")
            axes[0].set_ylabel("Lignes (échantillon de 500)")
            
            # Barplot du % de NaN
            # Trier du plus petit au plus grand pour lisibilité horizontale
            pct_nan = avec_nan.set_index("Colonne")["% NaN"].sort_values()
            
            pct_nan.plot(
                kind="barh",          # Barres horizontales (h = horizontal)
                ax=axes[1],
                color=[
                    "#FF6B6B" if v > 20 else "#FFD93D" if v > 5 else "#6BCB77"
                    for v in pct_nan.values
                ],
                edgecolor="white",
                linewidth=0.5
            )
            
            # Ajouter les valeurs à droite de chaque barre
            for i, (col, val) in enumerate(pct_nan.items()):
                axes[1].text(val + 0.2, i, f"{val:.1f}%",
                             va="center", fontsize=9)
            
            axes[1].set_title("Pourcentage de NaN par colonne", fontsize=11)
            axes[1].set_xlabel("% de valeurs manquantes")
            axes[1].axvline(5,  color="orange", linestyle="--", alpha=0.7,
                            label="Seuil 5%")
            axes[1].axvline(20, color="red",    linestyle="--", alpha=0.7,
                            label="Seuil 20%")
            axes[1].legend(fontsize=9)
            
            plt.suptitle("Audit des Valeurs Manquantes — Dataset Olist",
                         fontsize=13, fontweight="bold")
            plt.tight_layout()
            
            # Sauvegarder le graphique
            FIGURES_DIR.mkdir(parents=True, exist_ok=True)
            fig.savefig(FIGURES_DIR / "audit_nan.png", dpi=150,
                        bbox_inches="tight")
            plt.show()
            print(f"  -> Graphique sauvegardé : reports/figures/audit_nan.png")
        
        return df_rapport
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 2 : TRAITEMENT DES DATES MANQUANTES
    # ─────────────────────────────────────────────────────────
    
    def traiter_dates_manquantes(self) -> "OlistDataCleaner":
        """
        Traite les dates manquantes de manière contextuelle.
        
        Stratégies selon la colonne :
        - order_delivered_customer_date : NaN si commande non livrée -> NORMAL
          -> Créer un indicateur 'livraison_effective' (bool)
        - order_approved_at : NaN si commande non approuvée -> NORMAL
          -> Ne pas imputer, utiliser l'indicateur
        - order_delivered_carrier_date : NaN avant prise en charge -> NORMAL
        
        Returns:
            self -> permet le chaînage de méthodes
        
        Example:
            cleaner.traiter_dates_manquantes().traiter_prix()
        """
        avant = len(self.df)
        log("Traitement des dates manquantes...", "INFO")
        
        # ─── Indicateur : commande effectivement livrée ───────────────
        # np.where(condition, valeur_si_vrai, valeur_si_faux)
        # Si order_delivered_customer_date n'est pas NaN -> True (livrée)
        self.df["est_livree"] = np.where(
            self.df["order_delivered_customer_date"].notna(),  # notna() = NON NaN
            True,   # Si la date est présente -> commande livrée
            False   # Sinon -> non livrée
        )
        
        # ─── Calculer le délai de livraison réel ─────────────────────
        # Soustraction de dates -> retourne un Timedelta (durée)
        # .dt.days -> extraire le nombre de jours en entier
        self.df["delai_livraison_jours"] = (
            self.df["order_delivered_customer_date"]
            - self.df["order_purchase_timestamp"]
        ).dt.days
        
        # ─── Calculer le délai estimé ────────────────────────────────
        self.df["delai_estime_jours"] = (
            self.df["order_estimated_delivery_date"]
            - self.df["order_purchase_timestamp"]
        ).dt.days
        
        # ─── Calculer le retard (négatif = en avance) ────────────────
        # Si livré avant la date estimée -> négatif (en avance)
        # Si livré après la date estimée -> positif (en retard)
        self.df["retard_jours"] = (
            self.df["order_delivered_customer_date"]
            - self.df["order_estimated_delivery_date"]
        ).dt.days
        
        # ─── Indicateur booléen : commande en retard ─────────────────
        # > 0 jours de retard -> True
        # notna() car retard_jours est NaN si non livré
        self.df["est_en_retard"] = (
            self.df["retard_jours"].notna() &
            (self.df["retard_jours"] > 0)
        )
        
        # ─── Calculer le délai d'approbation ─────────────────────────
        self.df["delai_approbation_heures"] = (
            self.df["order_approved_at"]
            - self.df["order_purchase_timestamp"]
        ).dt.total_seconds() / 3600  # Convertir secondes -> heures
        
        # ─── Variables temporelles ───────────────────────────────────
        # .dt -> accesseur datetime de pandas
        # .year, .month, .day_of_week, etc.
        self.df["annee_commande"]    = self.df["order_purchase_timestamp"].dt.year
        self.df["mois_commande"]     = self.df["order_purchase_timestamp"].dt.month
        self.df["jour_semaine"]      = self.df["order_purchase_timestamp"].dt.day_name()
        self.df["heure_commande"]    = self.df["order_purchase_timestamp"].dt.hour
        self.df["trimestre"]         = self.df["order_purchase_timestamp"].dt.quarter
        self.df["est_weekend"]       = self.df["order_purchase_timestamp"].dt.dayofweek >= 5
        
        self._journaliser("Traitement dates", avant, len(self.df),
                          "Nouvelles variables : est_livree, delai_livraison_jours, retard_jours, etc.")
        
        return self  # Retourner self -> permet le chaînage
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 3 : TRAITEMENT DES PRIX ET MONTANTS
    # ─────────────────────────────────────────────────────────
    
    def traiter_prix(self) -> "OlistDataCleaner":
        """
        Nettoie et valide les colonnes de prix.
        
        Problèmes courants dans les prix e-commerce :
        1. Prix à 0 (produit gratuit ou erreur de saisie)
        2. Prix négatifs (erreur)
        3. Prix extrêmement élevés (outliers)
        4. Frais de port négatifs (erreur)
        
        Stratégie :
        - Prix = 0 -> marquer comme potentiellement problématique
        - Prix < 0 -> erreur -> mettre à NaN
        - Prix > P99.9 -> outlier extrême -> winsoriser à P99.9
        
        Returns:
            self -> chaînage
        """
        avant = len(self.df)
        log("Traitement des prix et montants...", "INFO")
        
        # ─── Vérifier si la colonne price existe ────────────────────
        # Certaines jointures peuvent ne pas avoir cette colonne
        if "price" not in self.df.columns:
            log("  Colonne 'price' absente — étape ignorée", "WARNING")
            return self
        
        # ─── Statistiques initiales ──────────────────────────────────
        prix_avant = self.df["price"].copy()
        print(f"\n  Prix avant traitement :")
        print(f"    Min    : R$ {prix_avant.min():.2f}")
        print(f"    Max    : R$ {prix_avant.max():.2f}")
        print(f"    Médiane: R$ {prix_avant.median():.2f}")
        print(f"    NaN    : {prix_avant.isnull().sum()}")
        
        # ─── Prix négatifs -> NaN ─────────────────────────────────────
        # loc[condition, colonne] = valeur -> modifier seulement les lignes qui
        # satisfont la condition
        n_negatifs = (self.df["price"] < 0).sum()
        if n_negatifs > 0:
            self.df.loc[self.df["price"] < 0, "price"] = np.nan
            log(f"  {n_negatifs} prix négatifs -> NaN", "WARNING")
        
        # ─── Prix à 0 -> marquer ──────────────────────────────────────
        # On ne supprime pas, on crée un indicateur pour analyse ultérieure
        self.df["prix_est_zero"] = (self.df["price"] == 0)
        n_zeros = self.df["prix_est_zero"].sum()
        log(f"  {n_zeros} prix à 0 (marqués dans 'prix_est_zero')", "INFO")
        
        # ─── Winsorisation (capping) des outliers ────────────────────
        # Calculer le 99.9ème percentile
        # quantile(0.999) -> valeur en dessous de laquelle 99.9% des prix se trouvent
        p999 = self.df["price"].quantile(0.999)
        p001 = self.df["price"].quantile(0.001)
        
        # clip(lower, upper) -> limiter les valeurs entre [lower, upper]
        # Valeurs > p999 -> remplacées par p999
        # Valeurs < p001 -> remplacées par p001 (si positif)
        n_outliers = ((self.df["price"] > p999) |
                      (self.df["price"] < p001)).sum()
        
        self.df["price"] = self.df["price"].clip(lower=p001, upper=p999)
        
        if n_outliers > 0:
            log(f"  {n_outliers} outliers de prix winsorisés à [R${p001:.2f}, R${p999:.2f}]",
                "INFO")
        
        # ─── Frais de port ───────────────────────────────────────────
        if "freight_value" in self.df.columns:
            # Frais de port négatifs -> erreur -> 0
            n_fret_neg = (self.df["freight_value"] < 0).sum()
            if n_fret_neg > 0:
                self.df.loc[self.df["freight_value"] < 0, "freight_value"] = 0
                log(f"  {n_fret_neg} frais de port négatifs -> 0", "WARNING")
        
        # ─── Calcul du montant total ──────────────────────────────────
        if "freight_value" in self.df.columns:
            # Montant total = prix produit + frais de port
            # fillna(0) -> si price ou freight_value est NaN -> considérer 0
            self.df["montant_total"] = (
                self.df["price"].fillna(0) +
                self.df["freight_value"].fillna(0)
            )
        
        # ─── Statistiques après traitement ───────────────────────────
        print(f"\n  Prix après traitement :")
        print(f"    Min    : R$ {self.df['price'].min():.2f}")
        print(f"    Max    : R$ {self.df['price'].max():.2f}")
        print(f"    Médiane: R$ {self.df['price'].median():.2f}")
        print(f"    NaN    : {self.df['price'].isnull().sum()}")
        
        self._journaliser("Traitement prix", avant, len(self.df),
                          f"Outliers winsorisés, prix négatifs -> NaN")
        return self
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 4 : SUPPRIMER LES DOUBLONS
    # ─────────────────────────────────────────────────────────
    
    def supprimer_doublons(self) -> "OlistDataCleaner":
        """
        Détecte et supprime les lignes dupliquées.
        
        Dans le dataset Olist, un doublon est une ligne avec
        le même (order_id, order_item_id) -> identifiant composite unique.
        
        Stratégies :
        - Doublons exacts : supprimer (garder la première occurrence)
        - Doublons partiels : analyser avant de décider
        
        Returns:
            self -> chaînage
        """
        avant = len(self.df)
        log("Détection et suppression des doublons...", "INFO")
        
        # ─── Doublons exacts (toutes colonnes identiques) ────────────
        # duplicated() -> masque booléen True si la ligne est un doublon
        # keep="first" -> marquer les doublons APRÈS la première occurrence
        n_doublons_exacts = self.df.duplicated().sum()
        print(f"\n  Doublons exacts : {n_doublons_exacts:,}")
        
        if n_doublons_exacts > 0:
            # drop_duplicates() -> supprimer les lignes dupliquées
            self.df = self.df.drop_duplicates(keep="first")
            log(f"  {n_doublons_exacts} doublons exacts supprimés", "INFO")
        
        # ─── Doublons sur la clé métier ───────────────────────────────
        # Dans le dataset items : (order_id, order_item_id) est la clé unique
        if "order_item_id" in self.df.columns:
            n_doublons_cle = self.df.duplicated(
                subset=["order_id", "order_item_id"]
            ).sum()
            
            print(f"  Doublons (order_id, order_item_id) : {n_doublons_cle:,}")
            
            if n_doublons_cle > 0:
                self.df = self.df.drop_duplicates(
                    subset=["order_id", "order_item_id"],
                    keep="first"
                )
                log(f"  {n_doublons_cle} doublons de clé métier supprimés", "WARNING")
        
        # ─── Réinitialiser l'index ────────────────────────────────────
        # Après suppression, l'index a des "trous" (ex: 0, 1, 5, 8...)
        # reset_index(drop=True) -> renuméroter de 0 à n-1
        self.df = self.df.reset_index(drop=True)
        
        self._journaliser("Suppression doublons", avant, len(self.df))
        return self
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 5 : CORRIGER LES TYPES DE DONNÉES
    # ─────────────────────────────────────────────────────────
    
    def corriger_types(self) -> "OlistDataCleaner":
        """
        Corrige les types de données incorrects.
        
        Problèmes courants :
        - Colonnes numériques en object (string) car valeurs mixtes
        - Colonnes catégorielles en object -> convertir en category
        - Booléens stockés en int (0/1)
        
        Returns:
            self -> chaînage
        """
        log("Correction des types de données...", "INFO")
        
        # ─── Variables catégorielles -> category ────────────────────────
        # Le type "category" est plus efficace en mémoire pour les
        # colonnes avec peu de valeurs uniques répétées souvent.
        # Économie mémoire typique : 50-90%
        
        colonnes_categorielle = [
            "order_status",     # 8 valeurs possibles
            "payment_type",     # ex: credit_card, boleto, voucher, debit_card
        ]
        
        for col in colonnes_categorielle:
            if col in self.df.columns:
                avant_mem = self.df[col].memory_usage(deep=True)
                
                # astype("category") -> convertir en type catégoriel
                self.df[col] = self.df[col].astype("category")
                
                apres_mem = self.df[col].memory_usage(deep=True)
                reduction = (1 - apres_mem / avant_mem) * 100
                
                log(f"  {col}: object -> category "
                    f"(mémoire réduite de {reduction:.0f}%)", "INFO")
        
        # ─── Variables ordinales -> Categorical ordonnée ────────────────
        if "review_score" in self.df.columns:
            # Les scores d'avis ont un ordre naturel : 1 < 2 < 3 < 4 < 5
            # Categorical avec ordered=True -> permet les comparaisons < >
            self.df["review_score"] = pd.Categorical(
                self.df["review_score"],
                categories=[1, 2, 3, 4, 5],  # Ordre explicite
                ordered=True                   # La variable est ordinale
            )
            log("  review_score -> Categorical ordonnée (1 < 2 < 3 < 4 < 5)", "INFO")
        
        # ─── Colonnes numériques en object -> float ────────────────────
        # Parfois, une colonne numérique a des valeurs string mélangées
        # pd.to_numeric avec errors='coerce' -> convertit les non-numériques en NaN
        colonnes_numeriques_a_corriger = [
            "payment_installments",
            "payment_value"
        ]
        
        for col in colonnes_numeriques_a_corriger:
            if col in self.df.columns and self.df[col].dtype == object:
                self.df[col] = pd.to_numeric(self.df[col], errors="coerce")
                log(f"  {col}: object -> float (non-numériques -> NaN)", "INFO")
        
        # ─── Entiers qui peuvent l'être ────────────────────────────────
        # payment_installments est toujours entier (1, 2, 3...)
        if "payment_installments" in self.df.columns:
            # Int64 (avec majuscule) supporte les NaN
            # int64 (sans majuscule) ne supporte PAS les NaN
            self.df["payment_installments"] = (
                self.df["payment_installments"]
                .astype("Int64")  # Int64 nullable = supporte NaN
            )
        
        return self
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 6 : NORMALISATION DES TEXTES
    # ─────────────────────────────────────────────────────────
    
    def normaliser_textes(self) -> "OlistDataCleaner":
        """
        Normalise les colonnes de type texte.
        
        Opérations :
        - Supprimer les espaces en début/fin (strip)
        - Convertir en minuscules (lower)
        - Remplacer les underscore par des espaces (pour les catégories)
        
        Returns:
            self -> chaînage
        """
        log("Normalisation des colonnes textuelles...", "INFO")
        
        # ─── États brésiliens ─────────────────────────────────────────
        if "customer_state" in self.df.columns:
            # Certains états peuvent avoir des espaces parasites
            self.df["customer_state"] = (
                self.df["customer_state"]
                .str.strip()   # Supprimer espaces début/fin
                .str.upper()   # Tout en majuscules (SP, RJ, MG...)
            )
        
        # ─── Catégories de produits ───────────────────────────────────
        if "product_category_name" in self.df.columns:
            # Les catégories Olist utilisent des underscores
            # Ex: "beleza_saude" -> "beleza saude" (pour l'affichage)
            self.df["product_category_name_clean"] = (
                self.df["product_category_name"]
                .str.strip()
                .str.lower()
                .str.replace("_", " ", regex=False)  # _ -> espace
            )
        
        # ─── Catégories anglaises ─────────────────────────────────────
        if "category_english" in self.df.columns:
            self.df["category_english"] = (
                self.df["category_english"]
                .str.strip()
                .str.lower()
                .str.replace("_", " ", regex=False)
                .fillna("unknown")  # NaN -> "unknown" pour les catégories non traduites
            )
        
        return self
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 7 : FEATURE ENGINEERING (NOUVELLES VARIABLES)
    # ─────────────────────────────────────────────────────────
    
    def feature_engineering(self) -> "OlistDataCleaner":
        """
        Crée de nouvelles variables utiles pour l'analyse.
        
        Variables créées :
        - satisfaction_binaire : score ≥ 4 -> satisfait
        - categorie_delai : délai groupé en catégories
        - tranche_prix : prix groupé en tranches
        - revenu_vendeur : prix - frais port (approximation)
        - saison : printemps/été/automne/hiver brésilien
        
        Returns:
            self -> chaînage
        """
        log("Feature engineering — création de nouvelles variables...", "INFO")
        
        # ─── Satisfaction binaire ────────────────────────────────────
        if "review_score" in self.df.columns:
            # Score ≥ 4 -> client satisfait
            # np.where(condition, valeur_si_vrai, valeur_si_faux)
            self.df["client_satisfait"] = np.where(
                self.df["review_score"].astype("float") >= 4,
                True,
                False
            )
            
            # Aussi créer une variable textuelle
            self.df["satisfaction_label"] = np.where(
                self.df["review_score"].astype("float") >= 4,
                "Satisfait",
                "Insatisfait"
            )
        
        # ─── Catégories de délai ─────────────────────────────────────
        if "delai_livraison_jours" in self.df.columns:
            # pd.cut -> discrétiser une variable continue en catégories
            # bins -> bornes des intervalles
            # labels -> noms des catégories
            # right=True -> intervalles fermés à droite : (0, 7]
            self.df["categorie_delai"] = pd.cut(
                self.df["delai_livraison_jours"],
                bins=[-np.inf, 7, 14, 21, 30, np.inf],
                labels=["Express (<7j)", "Rapide (7-14j)",
                        "Standard (14-21j)", "Long (21-30j)",
                        "Très long (>30j)"],
                right=True
            )
        
        # ─── Tranches de prix ─────────────────────────────────────────
        if "price" in self.df.columns:
            # pd.qcut -> tranches avec effectifs ÉGAUX (quartiles)
            # q=4 -> 4 quartiles (Q1, Q2, Q3, Q4)
            # duplicates="drop" -> éviter l'erreur si les bornes sont identiques
            self.df["tranche_prix"] = pd.qcut(
                self.df["price"].dropna(),
                q=4,
                labels=["Budget (Q1)", "Moyen-bas (Q2)",
                        "Moyen-haut (Q3)", "Premium (Q4)"],
                duplicates="drop"
            )
        
        # ─── Saison (Hémisphère Sud : saisons inversées) ─────────────
        if "mois_commande" in self.df.columns:
            # Brésil = Hémisphère Sud -> été en décembre-mars
            def mois_vers_saison(mois):
                """Convertit un numéro de mois en saison brésilienne."""
                if mois in [12, 1, 2]:
                    return "Été"      # Décembre-Février
                elif mois in [3, 4, 5]:
                    return "Automne"  # Mars-Mai
                elif mois in [6, 7, 8]:
                    return "Hiver"    # Juin-Août
                else:
                    return "Printemps"  # Septembre-Novembre
            
            # .apply() -> appliquer la fonction sur chaque valeur de la colonne
            self.df["saison"] = self.df["mois_commande"].apply(mois_vers_saison)
        
        # ─── Score de performance vendeur ────────────────────────────
        if all(col in self.df.columns for col in
               ["retard_jours", "review_score"]):
            
            # Performance basée sur délai et satisfaction
            # Créé avec une règle métier simple
            def calculer_performance(row):
                """Score de performance du vendeur (0-100)."""
                score = 50  # Score de base
                
                # Bonus/malus selon le délai
                if pd.notna(row.get("retard_jours")):
                    retard = row["retard_jours"]
                    if retard <= -7:    score += 30   # En avance d'une semaine
                    elif retard <= 0:   score += 15   # Juste à temps
                    elif retard <= 3:   score -= 10   # Légèrement en retard
                    else:               score -= 25   # Très en retard
                
                # Bonus/malus selon la satisfaction
                review = row.get("review_score")
                if pd.notna(review):
                    review_float = float(review)
                    score += (review_float - 3) * 10  # Centré sur 3/5
                
                # Clip entre 0 et 100
                return max(0, min(100, score))
            
            # apply avec axis=1 -> appliquer sur chaque LIGNE (pas colonne)
            self.df["score_performance"] = self.df.apply(
                calculer_performance, axis=1
            )
        
        log(f"  Variables créées : client_satisfait, categorie_delai, "
            f"tranche_prix, saison, score_performance", "INFO")
        
        return self
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 8 : VALIDER LES RÈGLES MÉTIER
    # ─────────────────────────────────────────────────────────
    
    def valider_regles_metier(self) -> Dict[str, int]:
        """
        Vérifie que les règles métier sont respectées.
        
        Règles Olist :
        1. La date de livraison réelle doit être APRÈS la date d'achat
        2. La date estimée doit être APRÈS la date d'achat
        3. Le prix doit être positif (> 0)
        4. Le review_score doit être entre 1 et 5
        5. L'état brésilien doit être dans la liste officielle des 26 états
        
        Returns:
            Dictionnaire {règle: nombre de violations}
        """
        print("\n" + "─"*60)
        print("  VALIDATION DES RÈGLES MÉTIER")
        print("─"*60)
        
        violations = {}
        
        # ─── Règle 1 : Date livraison > Date achat ─────────────────────
        if all(col in self.df.columns
               for col in ["order_delivered_customer_date",
                            "order_purchase_timestamp"]):
            
            mask_livraisons = self.df["order_delivered_customer_date"].notna()
            livraisons = self.df[mask_livraisons]
            
            violation_dates = (
                livraisons["order_delivered_customer_date"] <
                livraisons["order_purchase_timestamp"]
            ).sum()
            
            violations["livraison_avant_achat"] = violation_dates
            statut = "[OK]" if violation_dates == 0 else "[X]"
            print(f"  {statut} Livraison après achat  : {violation_dates} violation(s)")
        
        # ─── Règle 2 : Prix > 0 ───────────────────────────────────────
        if "price" in self.df.columns:
            v = (self.df["price"] <= 0).sum()
            violations["prix_non_positif"] = v
            statut = "[OK]" if v == 0 else "[ATTENTION] "
            print(f"  {statut} Prix > 0               : {v} violation(s)")
        
        # ─── Règle 3 : Score avis 1-5 ─────────────────────────────────
        if "review_score" in self.df.columns:
            scores = self.df["review_score"].dropna().astype(float)
            v = ((scores < 1) | (scores > 5)).sum()
            violations["score_hors_plage"] = v
            statut = "[OK]" if v == 0 else "[X]"
            print(f"  {statut} Score avis [1-5]        : {v} violation(s)")
        
        # ─── Règle 4 : États brésiliens valides ───────────────────────
        if "customer_state" in self.df.columns:
            # Les 27 unités fédératives brésiliennes (26 états + DF)
            etats_valides = {
                "AC", "AL", "AP", "AM", "BA", "CE", "DF", "ES", "GO",
                "MA", "MT", "MS", "MG", "PA", "PB", "PR", "PE", "PI",
                "RJ", "RN", "RS", "RO", "RR", "SC", "SP", "SE", "TO"
            }
            
            etats_dataset = set(self.df["customer_state"].dropna().unique())
            etats_invalides = etats_dataset - etats_valides
            v = self.df["customer_state"].isin(etats_invalides).sum()
            
            violations["etats_invalides"] = v
            statut = "[OK]" if v == 0 else "[X]"
            print(f"  {statut} États BR valides        : {v} violation(s)", end="")
            if etats_invalides:
                print(f" -> {etats_invalides}")
            else:
                print()
        
        # ─── Résumé ───────────────────────────────────────────────────
        total_violations = sum(violations.values())
        print(f"\n  Total violations : {total_violations}")
        
        if total_violations == 0:
            print("  [OK] Toutes les règles métier sont respectées !")
        else:
            print("  [ATTENTION]  Des violations ont été détectées — vérification manuelle recommandée")
        
        return violations
    
    
    # ─────────────────────────────────────────────────────────
    # MÉTHODE 9 : PIPELINE COMPLET DE NETTOYAGE
    # ─────────────────────────────────────────────────────────
    
    @timeit
    def nettoyer(self) -> pd.DataFrame:
        """
        Exécute toutes les étapes de nettoyage en séquence.
        
        Le chaînage de méthodes (method chaining) permet d'écrire
        un pipeline lisible et maintenable.
        
        Returns:
            DataFrame nettoyé et enrichi
        
        Example:
            cleaner = OlistDataCleaner(df_brut)
            df_propre = cleaner.nettoyer()
        """
        print("\n" + "█"*60)
        print("█" + " "*18 + "PIPELINE DE NETTOYAGE" + " "*18 + "█")
        print("█"*60)
        
        # Afficher l'état initial
        print(f"\n  État initial : {self.df.shape[0]:,} lignes × {self.df.shape[1]} colonnes")
        
        # ─── PIPELINE : exécution séquentielle des étapes ─────────────
        (self
         .traiter_dates_manquantes()  # Étape 1 : Dates
         .traiter_prix()              # Étape 2 : Prix
         .supprimer_doublons()        # Étape 3 : Doublons
         .corriger_types()            # Étape 4 : Types
         .normaliser_textes()         # Étape 5 : Textes
         .feature_engineering()       # Étape 6 : Nouvelles variables
        )
        
        # Valider les règles métier (sans modifier les données)
        violations = self.valider_regles_metier()
        
        # Afficher l'état final
        print(f"\n  État final : {self.df.shape[0]:,} lignes × {self.df.shape[1]} colonnes")
        
        # Produire le rapport de nettoyage
        self._produire_rapport()
        
        return self.df
    
    
    def _produire_rapport(self) -> None:
        """
        Affiche le rapport récapitulatif de toutes les transformations.
        """
        print("\n" + "="*60)
        print("  RAPPORT DE NETTOYAGE")
        print("="*60)
        print(f"  {'Opération':<35} {'Avant':>8} {'Après':>8} {'Diff':>8}")
        print("  " + "-"*55)
        
        for entree in self.journal:
            diff = entree["apres"] - entree["avant"]
            diff_str = f"{diff:+,}" if diff != 0 else "—"
            print(f"  {entree['operation']:<35} "
                  f"{entree['avant']:>8,} "
                  f"{entree['apres']:>8,} "
                  f"{diff_str:>8}")
        
        print("  " + "="*55)
        print(f"  Dataset final : {self.df.shape[0]:,} lignes × {self.df.shape[1]} colonnes")
        print(f"  Nouvelles colonnes : {self.df.shape[1] - self.df_brut.shape[1]}")


# ─────────────────────────────────────────────────────────────
# POINT D'ENTRÉE
# ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    """
    Démonstration du pipeline de nettoyage.
    Nécessite d'avoir d'abord exécuté data_loader.py.
    """
    import sys
    sys.path.insert(0, "src")
    
    from data_loader import OlistDataLoader
    
    # Charger le dataset (depuis cache si disponible)
    chemin_cache = Path("data/processed/master_dataset.parquet")
    
    if chemin_cache.exists():
        print("Chargement depuis le cache Parquet...")
        df_brut = pd.read_parquet(chemin_cache)
    else:
        print("Cache non trouvé — rechargement depuis CSV...")
        loader = OlistDataLoader()
        loader.charger_tous()
        df_brut = loader.creer_dataset_maitre()
    
    print(f"Dataset brut : {df_brut.shape}")
    
    # Créer et exécuter le cleaner
    cleaner = OlistDataCleaner(df_brut)
    
    # Audit des NaN d'abord
    rapport_nan = cleaner.auditer_nan(afficher_graphique=True)
    
    # Nettoyer
    df_propre = cleaner.nettoyer()
    
    # Sauvegarder
    df_propre.to_parquet("data/processed/orders_clean.parquet", index=False)
    print(f"\n[OK] Dataset propre sauvegardé : data/processed/orders_clean.parquet")
    print(f"   Colonnes disponibles ({df_propre.shape[1]}) :")
    for col in df_propre.columns:
        print(f"   -> {col}: {df_propre[col].dtype}")
```

---

# 6⃣ EXPLICATION LIGNE PAR LIGNE — FOCUS

## Comprendre le Chaînage de Méthodes (Method Chaining)

```python
# Le chaînage permet d'écrire des pipelines lisibles
(self
 .traiter_dates_manquantes()
 .traiter_prix()
 .supprimer_doublons()
)

# C'est équivalent à :
self.traiter_dates_manquantes()
self.traiter_prix()
self.supprimer_doublons()

# MAIS : pour que ça fonctionne, chaque méthode doit
# retourner 'self' à la fin :
def ma_methode(self):
    # ... transformation ...
    return self  # <- OBLIGATOIRE pour le chaînage
```

## Comprendre pd.cut vs pd.qcut

```python
import pandas as pd
import numpy as np

donnees = [1, 5, 10, 15, 20, 100, 200, 300]

# pd.cut -> intervalles de LARGEUR ÉGALE
# bins=[0, 50, 100, 300] -> intervalles de même taille
resultat_cut = pd.cut(
    donnees,
    bins=[0, 50, 100, 300],
    labels=["Petit", "Moyen", "Grand"]
)
# [Petit, Petit, Petit, Petit, Petit, Grand, Grand, Grand]
# -> Déséquilibré ! Beaucoup dans "Petit"

# pd.qcut -> intervalles avec EFFECTIFS ÉGAUX (quantiles)
# q=3 -> 3 groupes avec le même nombre de valeurs
resultat_qcut = pd.qcut(
    donnees,
    q=3,
    labels=["Bas", "Médian", "Élevé"]
)
# [Bas, Bas, Bas, Médian, Médian, Élevé, Élevé, Élevé]
# -> Équilibré ! ~3 valeurs dans chaque groupe

# QUAND UTILISER LEQUEL ?
# pd.cut -> quand les bornes ont du sens métier (ex: 0-18ans, 18-65ans, 65+)
# pd.qcut -> quand on veut des groupes de taille égale pour l'analyse
```

## Comprendre np.where

```python
import numpy as np
import pandas as pd

scores = pd.Series([1, 3, 4, 5, 2, 4])

# np.where(condition, valeur_si_vrai, valeur_si_faux)
# Vectorisé -> appliqué sur chaque élément SANS BOUCLE

resultat = np.where(
    scores >= 4,          # Condition : score >= 4
    "Satisfait",          # Si True -> "Satisfait"
    "Insatisfait"         # Si False -> "Insatisfait"
)
# ["Insatisfait", "Insatisfait", "Satisfait", "Satisfait",
#  "Insatisfait", "Satisfait"]

# np.where imbriqué (comme if-elif-else)
categorie = np.where(
    scores >= 4,   "Satisfait",
    np.where(
        scores == 3, "Neutre",
        "Insatisfait"
    )
)
```

---

# 7⃣ ANALYSE ET INTERPRÉTATION

## Résultats Attendus — NaN dans Olist

| Colonne | % NaN | Mécanisme | Traitement |
|---------|-------|-----------|------------|
| `review_comment_message` | 41% | MAR (avis optionnel) | Indicateur + garder NaN |
| `review_comment_title` | 62% | MAR (optionnel) | Ignorer ou indicateur |
| `order_delivered_customer_date` | 3% | MAR (commandes non livrées) | Indicateur `est_livree` |
| `order_approved_at` | 0.1% | MAR (paiement refusé) | Indicateur |
| `product_category_name` | 0.3% | MCAR | Imputer "unknown" |

## Features Engineering — Ce Qu'on a Créé

| Variable | Type | Utilité |
|----------|------|---------|
| `est_livree` | bool | Filtrer les commandes livrées |
| `delai_livraison_jours` | int | Analyser la performance logistique |
| `retard_jours` | int | Mesurer les retards (KPI clé) |
| `est_en_retard` | bool | Taux de retard (% commandes) |
| `client_satisfait` | bool | Taux de satisfaction |
| `categorie_delai` | cat | Segmenter par vitesse |
| `tranche_prix` | cat | Segmenter par gamme de prix |
| `saison` | str | Analyse saisonnière |
| `annee_commande`, `mois_commande` | int | Séries temporelles |
| `est_weekend` | bool | Commandes week-end vs semaine |

---

# 8⃣ BONNES PRATIQUES

## [OK] Règles d'Or du Nettoyage

1. **Ne jamais modifier les données brutes** -> Toujours `df.copy()`
2. **Documenter chaque transformation** -> Journal des opérations
3. **Valider avant et après** -> Comparer les statistiques
4. **Préférer les opérations vectorisées** -> `np.where` > `apply` > boucle
5. **Tester sur un sous-ensemble** -> `nrows=10000` pour le développement
6. **Centraliser les règles métier** -> Pas de magic numbers éparpillés

---

# 9⃣ ERREURS FRÉQUENTES

## [X] Erreur 1 : fillna sur dates -> type incorrect

```python
# MAUVAIS : fillna avec 0 sur une colonne datetime
df["date_livraison"].fillna(0)  # <- 0 n'est pas une date !

# BON : remplacement contextuel
df["date_livraison_clean"] = df["date_livraison"].copy()
# Créer un indicateur plutôt que d'inventer une date
df["livraison_connue"] = df["date_livraison"].notna()
```

## [X] Erreur 2 : apply au lieu de np.where (performance)

```python
# MAUVAIS : apply est lent (1 appel Python par ligne)
df["satisfait"] = df["score"].apply(lambda x: x >= 4)
# Sur 100k lignes : ~2 secondes

# BON : np.where est vectorisé (C sous le capot)
df["satisfait"] = np.where(df["score"] >= 4, True, False)
# Sur 100k lignes : ~0.01 secondes (200x plus rapide !)
```

## [X] Erreur 3 : pd.cut sans gérer les NaN

```python
# MAUVAIS : pd.cut échoue si la colonne a des NaN
df["tranche"] = pd.cut(df["prix"], bins=[0, 100, 500, 1000])
# ValueError ou NaN dans le résultat (silencieux)

# BON : toujours passer les valeurs non-null à pd.cut
valeurs_clean = df["prix"].dropna()
df.loc[valeurs_clean.index, "tranche"] = pd.cut(
    valeurs_clean, bins=[0, 100, 500, 1000]
)
# Ou : df["tranche"] = pd.cut(df["prix"], bins=...)
# -> Pandas gère les NaN en les laissant NaN dans le résultat
```

---

# [10] EXERCICES

## [VERT] FACILE

**Ex 1 :** Calculez le taux de commandes livrées en retard.
```python
# Utiliser la colonne 'est_en_retard' créée par le cleaner
taux_retard = df["est_en_retard"].mean() * 100
print(f"Taux de retard : {taux_retard:.1f}%")
```

**Ex 2 :** Quelle est la distribution des scores d'avis (1 à 5) ?
Calculez les fréquences et proportions. Quel score est le plus fréquent ?

**Ex 3 :** Combien de commandes ont une date de livraison AVANT la date d'achat ?
(violation de règle métier)

## [JAUNE] INTERMÉDIAIRE

**Ex 4 :** Ajoutez une méthode `traiter_avis()` à `OlistDataCleaner` qui :
- Crée un indicateur `a_laisse_commentaire` (bool)
- Calcule la longueur du commentaire (0 si absent)
- Crée une variable `ton_commentaire` : Positif (score ≥ 4), Négatif (≤ 2), Neutre (3)

**Ex 5 :** Détectez les outliers dans `delai_livraison_jours` avec la méthode IQR.
Créez une colonne `delai_est_outlier` (bool). Combien y en a-t-il ?

**Ex 6 :** Créez une fonction `comparer_avant_apres(df_avant, df_apres, colonne)` qui
affiche les statistiques descriptives d'une colonne avant et après nettoyage.

## [ROUGE] AVANCÉ

**Ex 7 :** Implémentez une méthode `imputer_nan_knn(colonnes, k=5)` qui utilise
`sklearn.impute.KNNImputer` pour imputer les valeurs manquantes des colonnes
numériques. Comparez les distributions avant/après.

**Ex 8 :** Créez un rapport de nettoyage automatique au format HTML incluant :
- Tableau des NaN avant/après
- Histogrammes des colonnes numériques avant/après
- Tableau des règles métier validées
- Utiliser pandas `.to_html()` et des CSS inline

---

# 1⃣1⃣ CORRIGÉ EXERCICE 4 — Traiter les Avis

```python
def traiter_avis(self) -> "OlistDataCleaner":
    """
    Enrichit les colonnes d'avis clients.
    
    Variables créées :
    - a_laisse_commentaire : True si review_comment_message non-null
    - longueur_commentaire : nombre de caractères du commentaire
    - ton_commentaire : Positif / Neutre / Négatif selon le score
    
    Returns:
        self -> chaînage
    """
    log("Traitement des avis clients...", "INFO")
    
    if "review_comment_message" not in self.df.columns:
        log("  Colonne review_comment_message absente — étape ignorée", "WARNING")
        return self
    
    # ─── Indicateur : a laissé un commentaire ─────────────────────
    # notna() -> True si la valeur n'est PAS NaN
    self.df["a_laisse_commentaire"] = (
        self.df["review_comment_message"].notna() &
        (self.df["review_comment_message"].str.strip() != "")
        # .str.strip() -> supprimer les espaces
        # != "" -> éliminer les commentaires vides
    )
    
    # ─── Longueur du commentaire ──────────────────────────────────
    # .str.len() -> longueur de chaque chaîne
    # fillna(0) -> si NaN -> 0 caractères
    self.df["longueur_commentaire"] = (
        self.df["review_comment_message"]
        .str.strip()
        .str.len()
        .fillna(0)
        .astype(int)
    )
    
    # ─── Ton du commentaire (basé sur le score) ────────────────────
    if "review_score" in self.df.columns:
        # Convertir en float pour les comparaisons numériques
        scores = self.df["review_score"].astype("float")
        
        # np.select -> version multi-conditions de np.where
        # conditions -> liste de conditions testées dans l'ordre
        # choices -> valeurs retournées si la condition est vraie
        # default -> valeur si aucune condition n'est vraie
        conditions = [
            scores >= 4,     # Score 4-5 -> Positif
            scores <= 2,     # Score 1-2 -> Négatif
            scores == 3      # Score 3   -> Neutre
        ]
        choices = ["Positif", "Négatif", "Neutre"]
        
        self.df["ton_commentaire"] = np.select(
            conditions,
            choices,
            default="Non renseigné"  # Score NaN
        )
    
    # ─── Statistiques ────────────────────────────────────────────
    pct_commentaires = self.df["a_laisse_commentaire"].mean() * 100
    longueur_moy = self.df[self.df["a_laisse_commentaire"]]["longueur_commentaire"].mean()
    
    log(f"  {pct_commentaires:.1f}% de clients ont laissé un commentaire", "INFO")
    log(f"  Longueur moyenne des commentaires : {longueur_moy:.0f} caractères", "INFO")
    
    if "ton_commentaire" in self.df.columns:
        distribution = self.df["ton_commentaire"].value_counts(normalize=True)
        for ton, pct in distribution.items():
            log(f"  -> {ton} : {pct*100:.1f}%", "INFO")
    
    return self
```

---

# [IMPORTANT] RÉCAPITULATIF DE LA PARTIE 2

| Concept | Application |
|---------|------------|
| Copie défensive | `df_brut.copy()` -> protéger les données originales |
| Method chaining | `self.etape1().etape2().etape3()` -> pipeline lisible |
| np.where | Conditionnel vectorisé (200x plus rapide qu'apply) |
| pd.cut | Discrétiser en intervalles de largeur égale |
| pd.qcut | Discrétiser en intervalles d'effectif égal |
| .dt accessor | Extraire année, mois, heure, jour_semaine depuis datetime |
| .str accessor | Opérations string vectorisées (strip, lower, replace) |
| np.select | Extension multi-conditions de np.where |
| astype("category") | Économie mémoire pour colonnes catégorielles |
| reset_index(drop=True) | Renuméroter l'index après suppression |

---

*DataInsight Pro — Partie 2 | Prochaine étape -> Partie 3 : EDA Complète*

# [GRAPHIQUE] DataInsight Pro — PARTIE 3
## Analyse Exploratoire des Données (EDA) Complète

---

# 1⃣ CONTEXTE MÉTIER

## La Présentation au Comité de Direction

Votre responsable vous annonce :

> "La prochaine réunion du COMEX (Comité Exécutif) est dans 3 jours. Le CEO veut comprendre la santé de notre business e-commerce. Préparez une analyse complète : nos meilleures ventes, la satisfaction client, les tendances, les États qui performent. Je veux des insights actionnables, pas juste des chiffres."

### KPIs (Key Performance Indicators) à Calculer

1. **CA Global** : Chiffre d'affaires total 2016-2018
2. **Taux de satisfaction** : % commandes avec score ≥ 4
3. **Délai moyen de livraison** : En jours
4. **Taux de retard** : % commandes livrées après la date estimée
5. **Top catégories** : Par CA et par volume
6. **Top États** : Par revenus et croissance
7. **Évolution mensuelle** : Tendances temporelles

---

# 2⃣ OBJECTIFS PÉDAGOGIQUES

[OK] Calculer des statistiques descriptives avancées  
[OK] Analyser des distributions (histogramme, boxplot, violin)  
[OK] Étudier les corrélations entre variables  
[OK] Réaliser des analyses par groupe (groupby avancé)  
[OK] Analyser les séries temporelles  
[OK] Identifier des insights business concrets  
[OK] Produire un rapport EDA automatisé  

**Concepts :** groupby + agg, pivot_table, corrélation Pearson/Spearman, resample, rolling mean, value_counts, crosstab

---

# 4⃣ THÉORIE APPLIQUÉE

## Résumé des 4 Types d'Analyse

**Descriptive** -> Que s'est-il passé ?  
*Ex : CA total 2017 = R$ 8.5M*

**Diagnostique** -> Pourquoi est-ce arrivé ?  
*Ex : Le pic de novembre 2017 est dû au Black Friday brésilien*

**Prédictive** -> Que va-t-il se passer ?  
*Ex : Le CA de Q1 2019 sera estimé à R$ 3.2M (basé sur la tendance)*

**Prescriptive** -> Que doit-on faire ?  
*Ex : Investir dans les catégories informatique et mobilier qui croissent*

---

# 5⃣ IMPLÉMENTATION COMPLÈTE

## src/analysis.py

```python
# ============================================================
# src/analysis.py
# DataInsight Pro — Analyses Statistiques et KPIs Business
# ============================================================
# Ce module calcule tous les KPIs et analyses pour le rapport.
# Principe : fonctions pures (même entrée -> même sortie)
#            Pas d'effets de bord (pas de modification du df)
# ============================================================

import pandas as pd
import numpy as np
from scipy import stats
from typing import Dict, List, Optional, Tuple
from pathlib import Path
import sys

sys.path.insert(0, str(Path(__file__).parent))
from utils import log, formater_nombre


# ─────────────────────────────────────────────────────────────
# SECTION 1 : MÉTRIQUES GLOBALES
# ─────────────────────────────────────────────────────────────

def calculer_kpis_globaux(df: pd.DataFrame) -> Dict[str, float]:
    """
    Calcule les KPIs (Key Performance Indicators) globaux du business.
    
    Args:
        df: Le DataFrame nettoyé (output de OlistDataCleaner)
    
    Returns:
        Dictionnaire des KPIs avec leurs valeurs
    
    Example:
        >>> kpis = calculer_kpis_globaux(df_propre)
        >>> print(f"CA Total: R$ {kpis['ca_total']:,.0f}")
    """
    
    print("\n" + "="*65)
    print("  KPIs GLOBAUX — OLIST E-COMMERCE")
    print("="*65)
    
    kpis = {}
    
    # ─── Métriques de volume ──────────────────────────────────────
    # len(df) -> nombre total de lignes (items de commandes)
    kpis["n_total_items"]     = len(df)
    kpis["n_commandes"]       = df["order_id"].nunique()
    kpis["n_clients_uniques"] = df["customer_unique_id"].nunique() \
                                if "customer_unique_id" in df.columns \
                                else df["customer_id"].nunique()
    kpis["n_vendeurs"]        = df["seller_id"].nunique() \
                                if "seller_id" in df.columns else 0
    kpis["n_produits"]        = df["product_id"].nunique() \
                                if "product_id" in df.columns else 0
    
    # ─── Métriques financières ────────────────────────────────────
    if "price" in df.columns:
        # Chiffre d'affaires total (somme des prix de vente)
        kpis["ca_total"]   = df["price"].sum()
        kpis["ca_moyen"]   = df["price"].mean()     # Panier moyen
        kpis["ca_median"]  = df["price"].median()
        
        # Revenu moyen par commande
        kpis["revenu_par_commande"] = (
            df.groupby("order_id")["price"].sum().mean()
        )
    
    if "freight_value" in df.columns:
        kpis["total_fret"] = df["freight_value"].sum()
        kpis["fret_moyen"] = df["freight_value"].mean()
    
    # ─── Métriques de livraison ───────────────────────────────────
    if "delai_livraison_jours" in df.columns:
        livrees = df[df["delai_livraison_jours"].notna()]
        kpis["delai_moyen_jours"]  = livrees["delai_livraison_jours"].mean()
        kpis["delai_median_jours"] = livrees["delai_livraison_jours"].median()
    
    if "est_en_retard" in df.columns:
        # Taux de retard = % commandes livrées après la date estimée
        livrees = df[df["est_livree"] == True] if "est_livree" in df.columns else df
        kpis["taux_retard"] = livrees["est_en_retard"].mean() * 100
    
    if "est_livree" in df.columns:
        kpis["taux_livraison"] = df["est_livree"].mean() * 100
    
    # ─── Métriques de satisfaction ───────────────────────────────
    if "review_score" in df.columns:
        scores = df["review_score"].astype("float").dropna()
        kpis["score_moyen"]    = scores.mean()
        kpis["score_median"]   = scores.median()
        kpis["taux_satisfaction"] = (scores >= 4).mean() * 100
        kpis["taux_mauvaises_notes"] = (scores <= 2).mean() * 100
    
    # ─── Affichage formaté ───────────────────────────────────────
    print(f"\n  VOLUME")
    print(f"  ├─ Items total     : {kpis.get('n_total_items', 0):>12,}")
    print(f"  ├─ Commandes       : {kpis.get('n_commandes', 0):>12,}")
    print(f"  ├─ Clients uniques : {kpis.get('n_clients_uniques', 0):>12,}")
    print(f"  ├─ Vendeurs        : {kpis.get('n_vendeurs', 0):>12,}")
    print(f"  └─ Produits        : {kpis.get('n_produits', 0):>12,}")
    
    print(f"\n  FINANCES")
    print(f"  ├─ CA Total        : {formater_nombre(kpis.get('ca_total', 0), devise=True):>18}")
    print(f"  ├─ CA Moyen/item   : {formater_nombre(kpis.get('ca_moyen', 0), devise=True):>18}")
    print(f"  ├─ Rev/commande    : {formater_nombre(kpis.get('revenu_par_commande', 0), devise=True):>18}")
    print(f"  └─ Total Fret      : {formater_nombre(kpis.get('total_fret', 0), devise=True):>18}")
    
    print(f"\n  LIVRAISON")
    print(f"  ├─ Taux livraison  : {kpis.get('taux_livraison', 0):>10.1f}%")
    print(f"  ├─ Délai moyen     : {kpis.get('delai_moyen_jours', 0):>10.1f} jours")
    print(f"  ├─ Délai médian    : {kpis.get('delai_median_jours', 0):>10.1f} jours")
    print(f"  └─ Taux retard     : {kpis.get('taux_retard', 0):>10.1f}%")
    
    print(f"\n  SATISFACTION")
    print(f"  ├─ Score moyen     : {kpis.get('score_moyen', 0):>10.2f} / 5")
    print(f"  ├─ Score médian    : {kpis.get('score_median', 0):>10.1f} / 5")
    print(f"  ├─ Taux satisfaction: {kpis.get('taux_satisfaction', 0):>9.1f}%")
    print(f"  └─ Taux mauvaises  : {kpis.get('taux_mauvaises_notes', 0):>9.1f}%")
    
    return kpis


# ─────────────────────────────────────────────────────────────
# SECTION 2 : ANALYSE PAR CATÉGORIE
# ─────────────────────────────────────────────────────────────

def analyser_categories(df: pd.DataFrame, top_n: int = 15) -> pd.DataFrame:
    """
    Analyse les performances par catégorie de produits.
    
    Calcule pour chaque catégorie :
    - CA total et moyen
    - Nombre de commandes et de produits
    - Score de satisfaction moyen
    - Délai de livraison moyen
    
    Args:
        df    : DataFrame nettoyé
        top_n : Nombre de catégories à retourner
    
    Returns:
        DataFrame des top_n catégories triées par CA décroissant
    """
    
    # Choisir la colonne de catégorie disponible
    col_cat = None
    for col in ["category_english", "product_category_name_clean",
                "product_category_name"]:
        if col in df.columns:
            col_cat = col
            break
    
    if col_cat is None:
        log("Aucune colonne de catégorie trouvée", "WARNING")
        return pd.DataFrame()
    
    # ─── Agrégation par catégorie ─────────────────────────────────
    # groupby().agg() -> Split-Apply-Combine
    # Named aggregations (pandas 0.25+) : nom_colonne=(colonne, fonction)
    
    stats_cat = (
        df[df[col_cat].notna()]  # Exclure les catégories inconnues
        .groupby(col_cat)
        .agg(
            # Métriques financières
            ca_total         = ("price", "sum"),
            ca_moyen         = ("price", "mean"),
            # Métriques de volume
            n_commandes      = ("order_id", "nunique"),
            n_items          = ("order_id", "count"),
            # Métriques satisfaction
            score_moyen      = ("review_score", lambda x: x.astype("float").mean()),
            # Métriques livraison
            delai_moyen      = ("delai_livraison_jours", "mean"),
            taux_retard_pct  = ("est_en_retard",
                                lambda x: x.mean() * 100 if x.notna().any() else 0),
        )
        .reset_index()
        .rename(columns={col_cat: "categorie"})
    )
    
    # ─── Calculs dérivés ─────────────────────────────────────────
    # Part de marché = CA catégorie / CA total * 100
    stats_cat["part_marche_pct"] = (
        stats_cat["ca_total"] / stats_cat["ca_total"].sum() * 100
    ).round(2)
    
    # CA par commande (panier moyen de la catégorie)
    stats_cat["ca_par_commande"] = (
        stats_cat["ca_total"] / stats_cat["n_commandes"]
    ).round(2)
    
    # ─── Tri et sélection du top N ───────────────────────────────
    # sort_values -> trier par CA décroissant
    # ascending=False -> du plus grand au plus petit
    top_categories = (
        stats_cat
        .sort_values("ca_total", ascending=False)
        .head(top_n)
        .round(2)
        .reset_index(drop=True)
    )
    
    print(f"\n  TOP {top_n} CATÉGORIES PAR CA :")
    print("  " + "─"*80)
    print(f"  {'Catégorie':<35} {'CA Total':>12} {'Part':>7} {'Score':>7} {'Délai':>7}")
    print("  " + "─"*80)
    
    for _, row in top_categories.iterrows():
        print(f"  {row['categorie']:<35} "
              f"R${row['ca_total']:>10,.0f} "
              f"{row['part_marche_pct']:>6.1f}% "
              f"{row['score_moyen']:>7.2f} "
              f"{row['delai_moyen']:>6.0f}j")
    
    return top_categories


# ─────────────────────────────────────────────────────────────
# SECTION 3 : ANALYSE GÉOGRAPHIQUE (PAR ÉTAT)
# ─────────────────────────────────────────────────────────────

def analyser_etats(df: pd.DataFrame) -> pd.DataFrame:
    """
    Analyse les performances par État brésilien.
    
    Brésil a 27 unités fédératives. Cette fonction calcule
    les KPIs pour chaque État et identifie les marchés clés.
    
    Args:
        df: DataFrame nettoyé avec customer_state
    
    Returns:
        DataFrame des performances par État
    """
    
    if "customer_state" not in df.columns:
        log("Colonne 'customer_state' manquante", "WARNING")
        return pd.DataFrame()
    
    # ─── Agrégation par État ──────────────────────────────────────
    stats_etats = (
        df.groupby("customer_state")
        .agg(
            ca_total        = ("price", "sum"),
            n_commandes     = ("order_id", "nunique"),
            n_clients       = ("customer_id", "nunique"),
            score_moyen     = ("review_score",
                               lambda x: x.astype("float").mean()),
            delai_moyen     = ("delai_livraison_jours", "mean"),
        )
        .reset_index()
        .rename(columns={"customer_state": "etat"})
    )
    
    # ─── Métriques dérivées ────────────────────────────────────────
    stats_etats["ca_par_client"] = (
        stats_etats["ca_total"] / stats_etats["n_clients"]
    )
    
    stats_etats["commandes_par_client"] = (
        stats_etats["n_commandes"] / stats_etats["n_clients"]
    )
    
    # Part du CA national
    ca_national = stats_etats["ca_total"].sum()
    stats_etats["part_ca_pct"] = (
        stats_etats["ca_total"] / ca_national * 100
    ).round(2)
    
    # Trier par CA décroissant
    stats_etats = stats_etats.sort_values("ca_total", ascending=False)
    
    # ─── Résumé ───────────────────────────────────────────────────
    print(f"\n  TOP 10 ÉTATS PAR CA :")
    print("  " + "─"*75)
    print(f"  {'État':<5} {'CA Total':>12} {'Part':>7} {'Clients':>8} "
          f"{'CA/Client':>10} {'Score':>7}")
    print("  " + "─"*75)
    
    for _, row in stats_etats.head(10).iterrows():
        print(f"  {row['etat']:<5} "
              f"R${row['ca_total']:>10,.0f} "
              f"{row['part_ca_pct']:>6.1f}% "
              f"{row['n_clients']:>8,} "
              f"R${row['ca_par_client']:>8,.0f} "
              f"{row['score_moyen']:>7.2f}")
    
    return stats_etats


# ─────────────────────────────────────────────────────────────
# SECTION 4 : ANALYSE TEMPORELLE
# ─────────────────────────────────────────────────────────────

def analyser_evolution_temporelle(df: pd.DataFrame) -> Dict[str, pd.DataFrame]:
    """
    Analyse l'évolution temporelle du business.
    
    Crée plusieurs granularités temporelles :
    - Evolution mensuelle du CA
    - Comparaison par trimestre
    - Patterns hebdomadaires (quel jour les gens commandent)
    - Patterns horaires (à quelle heure)
    
    Args:
        df: DataFrame avec colonnes temporelles
    
    Returns:
        Dictionnaire de DataFrames temporels
    """
    
    resultats = {}
    
    # ─── Évolution mensuelle du CA ────────────────────────────────
    if "order_purchase_timestamp" in df.columns and "price" in df.columns:
        
        # Créer un DataFrame avec date et CA
        df_ts = df[["order_purchase_timestamp", "price", "order_id"]].copy()
        
        # Resample -> regrouper par période temporelle
        # "ME" = Monthly End (fin de chaque mois)
        # Nécessite un index de type datetime
        df_ts = df_ts.set_index("order_purchase_timestamp")
        
        # Agrégation mensuelle
        mensuel = df_ts.resample("ME").agg(
            ca_mensuel  = ("price", "sum"),
            n_commandes = ("order_id", "nunique"),
        ).reset_index()
        
        mensuel["mois_label"] = mensuel["order_purchase_timestamp"].dt.strftime("%Y-%m")
        
        # Moyenne mobile sur 3 mois (lissage)
        # rolling(3) -> fenêtre glissante de 3 mois
        # .mean() -> moyenne de la fenêtre
        mensuel["ca_mm3"] = mensuel["ca_mensuel"].rolling(window=3, min_periods=1).mean()
        
        # Croissance M/M (Mois sur Mois)
        # pct_change() -> ((valeur_actuelle - valeur_precedente) / valeur_precedente) * 100
        mensuel["croissance_mm"] = mensuel["ca_mensuel"].pct_change() * 100
        
        resultats["mensuel"] = mensuel
        
        print(f"\n  ÉVOLUTION MENSUELLE (Derniers 6 mois disponibles) :")
        print("  " + "─"*65)
        print(f"  {'Mois':<12} {'CA':>12} {'Commandes':>10} {'Croissance':>12}")
        print("  " + "─"*65)
        
        for _, row in mensuel.tail(6).iterrows():
            croissance = row["croissance_mm"]
            croissance_str = (f"+{croissance:.1f}%" if croissance > 0
                              else f"{croissance:.1f}%" if pd.notna(croissance)
                              else "N/A")
            icone = "[HAUSSE]" if (pd.notna(croissance) and croissance > 0) else "[BAISSE]"
            
            print(f"  {row['mois_label']:<12} "
                  f"R${row['ca_mensuel']:>10,.0f} "
                  f"{row['n_commandes']:>10,} "
                  f"{icone} {croissance_str:>10}")
    
    # ─── Patterns par jour de la semaine ─────────────────────────
    if "jour_semaine" in df.columns:
        # Ordre logique des jours
        ordre_jours = ["Monday", "Tuesday", "Wednesday",
                       "Thursday", "Friday", "Saturday", "Sunday"]
        
        par_jour = (
            df.groupby("jour_semaine")
            .agg(
                n_commandes = ("order_id", "nunique"),
                ca_total    = ("price", "sum"),
                score_moyen = ("review_score", lambda x: x.astype("float").mean())
            )
            .reset_index()
        )
        
        # Réordonner selon l'ordre logique
        par_jour["jour_order"] = par_jour["jour_semaine"].map(
            {jour: i for i, jour in enumerate(ordre_jours)}
        )
        par_jour = par_jour.sort_values("jour_order")
        
        resultats["par_jour"] = par_jour
        
        print(f"\n  COMMANDES PAR JOUR DE LA SEMAINE :")
        for _, row in par_jour.iterrows():
            barre = "█" * int(row["n_commandes"] / par_jour["n_commandes"].max() * 20)
            print(f"  {row['jour_semaine']:<12} {barre:<22} {row['n_commandes']:>6,}")
    
    # ─── Patterns horaires ────────────────────────────────────────
    if "heure_commande" in df.columns:
        par_heure = (
            df.groupby("heure_commande")
            .agg(n_commandes = ("order_id", "nunique"))
            .reset_index()
            .sort_values("heure_commande")
        )
        
        resultats["par_heure"] = par_heure
        
        print(f"\n  COMMANDES PAR HEURE (Pic : "
              f"{par_heure.loc[par_heure['n_commandes'].idxmax(), 'heure_commande']}h) :")
        
        max_cmd = par_heure["n_commandes"].max()
        for _, row in par_heure.iterrows():
            h = int(row["heure_commande"])
            barre = "▓" * int(row["n_commandes"] / max_cmd * 30)
            print(f"  {h:02d}h  {barre:<32} {row['n_commandes']:>5,}")
    
    return resultats


# ─────────────────────────────────────────────────────────────
# SECTION 5 : ANALYSE DE CORRÉLATION
# ─────────────────────────────────────────────────────────────

def analyser_correlations(df: pd.DataFrame) -> pd.DataFrame:
    """
    Analyse les corrélations entre variables numériques.
    
    Utilise Pearson (relation linéaire) et Spearman (relation monotone).
    Identifie les relations les plus fortes avec la satisfaction client.
    
    Args:
        df: DataFrame nettoyé
    
    Returns:
        Matrice de corrélation Pearson
    """
    
    # Sélectionner les colonnes numériques pertinentes
    colonnes_analyse = [
        col for col in [
            "price", "freight_value", "delai_livraison_jours",
            "retard_jours", "review_score", "payment_installments",
            "delai_estime_jours", "delai_approbation_heures",
            "longueur_commentaire"
        ]
        if col in df.columns
    ]
    
    if len(colonnes_analyse) < 2:
        log("Pas assez de colonnes numériques pour l'analyse", "WARNING")
        return pd.DataFrame()
    
    # ─── Sélectionner et nettoyer les données ──────────────────────
    df_num = df[colonnes_analyse].copy()
    
    # Convertir review_score en numérique si nécessaire
    if "review_score" in df_num.columns:
        df_num["review_score"] = df_num["review_score"].astype("float")
    
    # ─── Matrice de corrélation Pearson ───────────────────────────
    corr_pearson = df_num.corr(method="pearson")
    
    # ─── Corrélations avec review_score (variable cible) ──────────
    if "review_score" in corr_pearson.columns:
        corr_avec_score = (
            corr_pearson["review_score"]
            .drop("review_score")       # Supprimer l'auto-corrélation (1.0)
            .sort_values(key=abs, ascending=False)  # Trier par valeur absolue
        )
        
        print("\n  CORRÉLATIONS AVEC LA SATISFACTION (review_score) :")
        print("  " + "─"*55)
        
        for feature, r in corr_avec_score.items():
            # Barre de progression proportionnelle
            barre_len = int(abs(r) * 20)
            barre = "+" * barre_len if r > 0 else "-" * barre_len
            
            # Force de la corrélation
            force = "Forte" if abs(r) > 0.5 else "Modérée" if abs(r) > 0.3 else "Faible"
            
            print(f"  {feature:<35} r={r:+.3f} [{barre:<22}] {force}")
    
    # ─── Test de significativité Spearman ────────────────────────
    if "review_score" in df_num.columns and "delai_livraison_jours" in df_num.columns:
        donnees_propres = df_num[["review_score", "delai_livraison_jours"]].dropna()
        
        rho, p_val = stats.spearmanr(
            donnees_propres["review_score"],
            donnees_propres["delai_livraison_jours"]
        )
        
        print(f"\n  Corrélation Spearman (score ~ délai) :")
        print(f"  ρ = {rho:.3f}, p-value = {p_val:.4e}")
        if p_val < 0.05:
            direction = "négatif" if rho < 0 else "positif"
            print(f"  -> Relation {direction} significative (p<0.05) [OK]")
    
    return corr_pearson


# ─────────────────────────────────────────────────────────────
# SECTION 6 : STATISTIQUES DESCRIPTIVES AVANCÉES
# ─────────────────────────────────────────────────────────────

def statistiques_completes(df: pd.DataFrame) -> None:
    """
    Affiche les statistiques descriptives complètes pour les
    variables numériques clés.
    
    Va au-delà de df.describe() en ajoutant :
    - Skewness et Kurtosis
    - Coefficient de variation
    - Tests de normalité
    - Percentiles étendus
    
    Args:
        df: DataFrame nettoyé
    """
    
    # Variables numériques à analyser
    variables_cibles = {
        "price":                    "Prix des produits (R$)",
        "freight_value":            "Frais de port (R$)",
        "delai_livraison_jours":    "Délai de livraison (jours)",
        "retard_jours":             "Retard de livraison (jours)",
        "review_score":             "Score d'avis (1-5)",
    }
    
    for col, label in variables_cibles.items():
        if col not in df.columns:
            continue
        
        serie = df[col].astype("float").dropna()
        
        print(f"\n  {'─'*60}")
        print(f"  [GRAPHIQUE] {label}")
        print(f"  {'─'*60}")
        print(f"  n (non-nuls)    : {len(serie):>10,}")
        print(f"  Moyenne         : {serie.mean():>10.3f}")
        print(f"  Médiane         : {serie.median():>10.3f}")
        print(f"  Mode            : {serie.mode().iloc[0]:>10.3f}")
        print(f"  Écart-type      : {serie.std():>10.3f}")
        print(f"  IQR (Q3-Q1)     : {serie.quantile(0.75) - serie.quantile(0.25):>10.3f}")
        print(f"  Min             : {serie.min():>10.3f}")
        print(f"  Max             : {serie.max():>10.3f}")
        print(f"  Skewness        : {serie.skew():>10.3f}", end="")
        
        # Interprétation du Skewness
        skew = serie.skew()
        if abs(skew) < 0.5:
            print(" -> Symétrique")
        elif skew > 0:
            print(" -> Asymétrie droite (longue queue à droite)")
        else:
            print(" -> Asymétrie gauche (longue queue à gauche)")
        
        print(f"  Kurtosis        : {serie.kurtosis():>10.3f}", end="")
        kurt = serie.kurtosis()
        if abs(kurt) < 0.5:
            print(" -> Distribution normale")
        elif kurt > 0:
            print(" -> Leptokurtique (queues épaisses)")
        else:
            print(" -> Platykurtique (queues fines)")
        
        # Coefficient de variation
        if serie.mean() != 0:
            cv = serie.std() / serie.mean() * 100
            print(f"  CV (std/mean)   : {cv:>10.1f}%")
        
        # Percentiles étendus
        print(f"  Percentiles     : P1={serie.quantile(0.01):.1f}, "
              f"P25={serie.quantile(0.25):.1f}, "
              f"P50={serie.quantile(0.50):.1f}, "
              f"P75={serie.quantile(0.75):.1f}, "
              f"P99={serie.quantile(0.99):.1f}")
        
        # Test de normalité (Shapiro sur un échantillon)
        sample = serie.sample(min(500, len(serie)), random_state=42)
        stat, p_val = stats.shapiro(sample)
        normale = p_val >= 0.05
        print(f"  Shapiro-Wilk    : p={p_val:.4f} -> "
              f"{'Normale' if normale else 'Non normale'} "
              f"{'[OK]' if not normale else '[ATTENTION] '}")
        # Attention : pour les grandes données, presque toujours non-normale


# ─────────────────────────────────────────────────────────────
# SECTION 7 : TABLEAU DE CONTINGENCE ET CHI-CARRÉ
# ─────────────────────────────────────────────────────────────

def analyser_association_categorielle(
    df: pd.DataFrame,
    col1: str,
    col2: str
) -> Dict:
    """
    Analyse l'association entre deux variables catégorielles.
    
    Utilise :
    - Tableau de contingence (crosstab)
    - Test du chi-carré (indépendance)
    - V de Cramer (force de l'association)
    
    Args:
        df  : DataFrame
        col1: Première variable catégorielle
        col2: Deuxième variable catégorielle
    
    Returns:
        Dictionnaire avec tableau, chi², p-value, Cramér's V
    """
    
    # ─── Tableau de contingence ───────────────────────────────────
    # pd.crosstab -> tableau croisé de fréquences
    tableau = pd.crosstab(
        df[col1],
        df[col2],
        margins=True,          # Ajouter les totaux
        margins_name="TOTAL"
    )
    
    # ─── Test du Chi-Carré ────────────────────────────────────────
    # chi2_contingency -> test d'indépendance entre les deux variables
    # H0 : les deux variables sont INDÉPENDANTES
    # H1 : il y a une association
    from scipy.stats import chi2_contingency
    
    tableau_sans_marges = pd.crosstab(df[col1], df[col2])
    chi2, p_val, dof, expected = chi2_contingency(tableau_sans_marges)
    
    # ─── V de Cramer ─────────────────────────────────────────────
    # Mesure de la FORCE de l'association (0 = indépendance, 1 = association parfaite)
    n = len(df)
    min_dim = min(tableau_sans_marges.shape) - 1
    cramers_v = np.sqrt(chi2 / (n * min_dim)) if min_dim > 0 else 0
    
    print(f"\n  Association : {col1} × {col2}")
    print(f"  Chi² = {chi2:.2f}, dof = {dof}, p = {p_val:.4e}")
    print(f"  V de Cramer = {cramers_v:.3f} "
          f"({'Forte' if cramers_v > 0.3 else 'Modérée' if cramers_v > 0.1 else 'Faible'})")
    
    if p_val < 0.05:
        print(f"  [OK] Association significative (p < 0.05)")
    else:
        print(f"  [X] Pas d'association significative (p ≥ 0.05)")
    
    return {
        "tableau":    tableau,
        "chi2":       chi2,
        "p_value":    p_val,
        "dof":        dof,
        "cramers_v":  cramers_v
    }


# ─────────────────────────────────────────────────────────────
# POINT D'ENTRÉE
# ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys
    from pathlib import Path
    sys.path.insert(0, "src")
    
    # Charger les données propres
    chemin = Path("data/processed/orders_clean.parquet")
    
    if not chemin.exists():
        print("Erreur : Exécutez d'abord data_loader.py et data_cleaning.py")
        sys.exit(1)
    
    df = pd.read_parquet(chemin)
    print(f"Dataset chargé : {df.shape}")
    
    # ─── Exécuter toutes les analyses ─────────────────────────────
    print("\n" + "█"*65)
    print("█" + " "*20 + "ANALYSE COMPLÈTE EDA" + " "*22 + "█")
    print("█"*65)
    
    # 1. KPIs globaux
    kpis = calculer_kpis_globaux(df)
    
    # 2. Statistiques descriptives
    print("\n[2] STATISTIQUES DESCRIPTIVES")
    statistiques_completes(df)
    
    # 3. Analyse des catégories
    print("\n[3] ANALYSE PAR CATÉGORIE")
    df_cats = analyser_categories(df, top_n=10)
    
    # 4. Analyse géographique
    print("\n[4] ANALYSE PAR ÉTAT")
    df_etats = analyser_etats(df)
    
    # 5. Analyse temporelle
    print("\n[5] ANALYSE TEMPORELLE")
    resultats_temporels = analyser_evolution_temporelle(df)
    
    # 6. Corrélations
    print("\n[6] CORRÉLATIONS")
    matrice_corr = analyser_correlations(df)
    
    # 7. Association catégorielle (exemple)
    if "order_status" in df.columns and "est_en_retard" in df.columns:
        print("\n[7] ASSOCIATIONS CATÉGORIELLES")
        # Convertir est_en_retard en string pour crosstab
        df["retard_str"] = df["est_en_retard"].map({True: "Retard", False: "À temps"})
        res = analyser_association_categorielle(df, "order_status", "retard_str")
    
    print("\n[OK] Analyse EDA complète terminée !")
```

---

# 6⃣ EXPLICATION LIGNE PAR LIGNE — FOCUS

## Comprendre groupby + agg (Named Aggregations)

```python
# SYNTAXE MODERNE (pandas 0.25+) — Named Aggregations
# nom_nouvelle_colonne = ("colonne_source", "fonction")
stats = df.groupby("categorie").agg(
    ca_total    = ("price", "sum"),         # Somme des prix
    n_commandes = ("order_id", "nunique"),  # Nombre de commandes uniques
    score_moyen = ("review_score", "mean"), # Moyenne des scores
)
# RÉSULTAT : DataFrame avec 3 colonnes : ca_total, n_commandes, score_moyen
# UNE LIGNE PAR CATÉGORIE

# ANCIENNE SYNTAXE (toujours valide mais moins lisible)
stats = df.groupby("categorie").agg({
    "price":       "sum",
    "order_id":    "nunique",
    "review_score": "mean"
}).rename(columns={"price": "ca_total", ...})
```

## Comprendre resample (séries temporelles)

```python
import pandas as pd

# resample -> regrouper par période temporelle
# NÉCESSITE un index de type datetime !

df_ts = df.set_index("order_purchase_timestamp")

# Agrégation mensuelle
mensuel = df_ts["price"].resample("ME").sum()
# "ME" = Monthly End (fin de chaque mois)

# Fréquences disponibles :
# "D"  -> Journalier
# "W"  -> Hebdomadaire (dimanche)
# "ME" -> Mensuel (fin de mois)
# "QE" -> Trimestriel
# "YE" -> Annuel

# Moyenne mobile (lissage)
# rolling(n) -> fenêtre glissante de n périodes
mensuel_lisse = mensuel.rolling(window=3, min_periods=1).mean()
# min_periods=1 -> calculer même avec moins de 3 valeurs (début de série)
```

## Comprendre pct_change

```python
import pandas as pd

ca_mensuel = pd.Series([100, 120, 110, 150, 140, 180])

# pct_change() -> variation relative entre chaque valeur et la précédente
variations = ca_mensuel.pct_change()
# [NaN, 0.20, -0.083, 0.364, -0.067, 0.286]
# -> Premier mois : NaN (pas de précédent)
# -> Mois 2 : (120-100)/100 = +20%
# -> Mois 3 : (110-120)/120 = -8.3%
# -> Mois 4 : (150-110)/110 = +36.4%

# En pourcentage
variations_pct = ca_mensuel.pct_change() * 100
```

---

# 7⃣ ANALYSE ET INTERPRÉTATION — INSIGHTS OLIST

## Ce que le Dataset Révèle (Insights Réels)

### Insight 1 : Le Pic Black Friday 2017
Le mois de novembre 2017 montre un pic de CA d'environ +35% vs octobre.
-> **Action** : Préparer les stocks et vendeurs 6 semaines avant le Black Friday

### Insight 2 : São Paulo Domine (≈40% du CA)
L'État SP génère environ 40% des revenus totaux.
-> **Action** : Optimiser la logistique SP en priorité

### Insight 3 : Délai vs Satisfaction (r ≈ -0.35)
Corrélation négative modérée entre délai de livraison et score.
-> **Action** : Réduire le délai de livraison améliore la satisfaction

### Insight 4 : Informatique et Maison = Top Catégories
"Cama, mesa e banho" (linge de maison) et "beleza saude" dominent.
-> **Action** : Investir dans ces catégories pour la croissance

### Insight 5 : 8h du matin = Heure de Pointe
Pic de commandes entre 20h et 22h (après le travail).
-> **Action** : Planifier les campagnes marketing pour 18h-20h

---

# 8⃣ BONNES PRATIQUES

## [OK] Analyse EDA — Règles d'Or

1. **Toujours commencer par l'univarié** -> Distribution de chaque variable seule
2. **Ensuite le bivarié** -> Relations entre 2 variables
3. **Puis le multivarié** -> Interactions complexes
4. **Documenter les insights** -> Pas juste des chiffres, des recommandations
5. **Valider les anomalies** -> Avant de tirer des conclusions
6. **Distinguer corrélation et causalité** -> Ne jamais dire "X cause Y" sans preuve

---

# 9⃣ ERREURS FRÉQUENTES

## [X] Erreur 1 : Confondre nunique et count

```python
# count -> compte les valeurs NON-NaN (peut compter des doublons)
df.groupby("categorie")["order_id"].count()

# nunique -> compte les valeurs DISTINCTES (pas de doublons)
df.groupby("categorie")["order_id"].nunique()

# Sur items : si une commande a 3 items de la même catégorie
# count -> 3 (un par item)
# nunique -> 1 (une seule commande unique)
```

## [X] Erreur 2 : resample sans index datetime

```python
# MAUVAIS : resample nécessite un index datetime
df["price"].resample("ME").sum()
# TypeError: Only valid with DatetimeIndex, TimedeltaIndex or PeriodIndex

# BON : définir l'index d'abord
df_ts = df.set_index("order_purchase_timestamp")
df_ts["price"].resample("ME").sum()
```

## [X] Erreur 3 : Interpréter la corrélation comme causalité

```python
# Corrélation trouvée : prix plus élevé -> score plus élevé
r = df["price"].corr(df["review_score"])  # r = 0.15

# MAUVAIS interprétation :
# "Les produits chers ont de meilleurs avis car ils sont de meilleure qualité"

# BON :
# "Il existe une faible corrélation positive. Hypothèses possibles :
#  - Les produits chers ont une meilleure qualité (relation causale directe)
#  - Les acheteurs de produits chers sont moins exigeants (biais de sélection)
#  - Variable confondante : catégorie de produit (électronique = cher ET bien noté)
#  -> Analyse complémentaire nécessaire"
```

---

# [10] EXERCICES

## [VERT] FACILE

**Ex 1 :** Calculez le CA mensuel pour chaque année.
Quelle année a le CA le plus élevé ? Quel mois est le meilleur ?

**Ex 2 :** Quelle est la distribution des scores d'avis ?
Tracez un barplot horizontal avec les pourcentages.

**Ex 3 :** Quels sont les 5 États avec le délai de livraison le plus LONG ?
Quels sont les 5 États avec le délai le plus COURT ?

## [JAUNE] INTERMÉDIAIRE

**Ex 4 :** Calculez la corrélation entre le délai de livraison et le score d'avis
POUR CHAQUE CATÉGORIE séparément (groupby + apply). Y a-t-il des différences ?

**Ex 5 :** Créez un pivot_table montrant le CA moyen par État (lignes)
et par type de paiement (colonnes). Affichez-le avec une heatmap.

**Ex 6 :** Identifiez les 10 vendeurs avec le meilleur et le pire score moyen
(minimum 50 commandes pour être éligible). Quelle est la différence de délai ?

## [ROUGE] AVANCÉ

**Ex 7 :** Implémentez une analyse de cohorte :
- Regrouper les clients par mois de première commande (cohorte)
- Calculer le taux de réachat pour chaque cohorte (clients qui ont repassé commande)
- Visualiser avec une heatmap : cohortes (lignes) × mois d'activité (colonnes)

**Ex 8 :** Réalisez une décomposition de la tendance en séries temporelles :
- Tendance (trend)
- Saisonnalité (seasonality)
- Résidu (residual)
- Utiliser `from statsmodels.tsa.seasonal import seasonal_decompose`

---

# 1⃣1⃣ CORRIGÉ — Exercice 7 (Analyse de Cohorte)

```python
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns


def analyser_cohortes(df: pd.DataFrame) -> pd.DataFrame:
    """
    Analyse de cohorte : taux de réachat par mois d'acquisition.
    
    Une cohorte = groupe de clients qui ont fait leur première commande
    le même mois. On suit leur comportement dans les mois suivants.
    
    Args:
        df: DataFrame avec customer_id et order_purchase_timestamp
    
    Returns:
        Matrice de cohorte (% de réachat par cohorte et mois)
    """
    
    df = df.copy()
    
    # ─── Étape 1 : Première commande par client ────────────────────
    # Pour chaque client, trouver la date de sa PREMIÈRE commande
    premiere_commande = (
        df.groupby("customer_id")["order_purchase_timestamp"]
        .min()             # min -> première date
        .reset_index()
        .rename(columns={"order_purchase_timestamp": "premiere_commande"})
    )
    
    # ─── Étape 2 : Définir la cohorte ─────────────────────────────
    # La cohorte = le mois de la première commande
    # dt.to_period("M") -> convertit en période mensuelle (ex: 2017-01)
    premiere_commande["cohorte"] = (
        premiere_commande["premiere_commande"]
        .dt.to_period("M")
    )
    
    # ─── Étape 3 : Joindre la cohorte au dataset principal ────────
    df = df.merge(
        premiere_commande[["customer_id", "cohorte"]],
        on="customer_id",
        how="left"
    )
    
    # ─── Étape 4 : Calculer le "numéro de mois" depuis l'acquisition
    # 0 = mois de la première commande
    # 1 = 1 mois après
    # etc.
    df["mois_commande_period"] = df["order_purchase_timestamp"].dt.to_period("M")
    df["mois_depuis_acquisition"] = (
        (df["mois_commande_period"] - df["cohorte"])
        .apply(lambda x: x.n)  # .n -> nombre de mois
    )
    
    # ─── Étape 5 : Nombre de clients actifs par cohorte et mois ───
    cohorte_table = (
        df.groupby(["cohorte", "mois_depuis_acquisition"])
        ["customer_id"]
        .nunique()
        .reset_index()
    )
    
    # ─── Étape 6 : Nombre de clients dans la cohorte initiale ─────
    taille_cohorte = (
        cohorte_table[cohorte_table["mois_depuis_acquisition"] == 0]
        [["cohorte", "customer_id"]]
        .rename(columns={"customer_id": "n_initial"})
    )
    
    cohorte_table = cohorte_table.merge(taille_cohorte, on="cohorte")
    
    # ─── Étape 7 : Taux de rétention ──────────────────────────────
    cohorte_table["taux_retention"] = (
        cohorte_table["customer_id"] / cohorte_table["n_initial"] * 100
    ).round(1)
    
    # ─── Étape 8 : Créer la matrice pivot ─────────────────────────
    # Lignes = cohortes, colonnes = mois depuis acquisition
    matrice = cohorte_table.pivot(
        index="cohorte",
        columns="mois_depuis_acquisition",
        values="taux_retention"
    )
    
    # ─── Visualisation ────────────────────────────────────────────
    plt.figure(figsize=(15, 8))
    
    # Garder seulement les 12 premières cohortes (éviter trop de NaN)
    matrice_viz = matrice.head(12).iloc[:, :12]  # 12 cohortes × 12 mois
    
    sns.heatmap(
        matrice_viz,
        annot=True,          # Afficher les valeurs
        fmt=".0f",           # Format sans décimales
        cmap="Blues",        # Palette bleue
        vmin=0, vmax=100,    # Échelle fixe [0%, 100%]
        linewidths=0.5,
        cbar_kws={"label": "% de rétention"}
    )
    
    plt.title("Analyse de Cohorte — Taux de Réachat (%)\n"
              "(Ligne = cohorte d'acquisition, Colonne = mois depuis 1ère commande)",
              fontsize=12)
    plt.xlabel("Mois depuis l'acquisition (0 = 1er achat)")
    plt.ylabel("Cohorte (mois de 1ère commande)")
    plt.tight_layout()
    plt.savefig("reports/figures/analyse_cohortes.png", dpi=150, bbox_inches="tight")
    plt.show()
    
    # ─── Insight ─────────────────────────────────────────────────
    retention_m1 = matrice.iloc[:, 1].dropna().mean()
    print(f"\n  Taux de réachat à 1 mois : {retention_m1:.1f}%")
    print(f"  Interprétation : {retention_m1:.0f}% des clients reviennent le mois suivant")
    
    return matrice


# Utilisation :
# matrice_cohorte = analyser_cohortes(df)
```

---

# [IMPORTANT] RÉCAPITULATIF DE LA PARTIE 3

| Concept | Application Olist |
|---------|------------------|
| `groupby().agg(named)` | KPIs par catégorie, état, vendeur |
| `resample("ME")` | CA mensuel sur 2 ans |
| `rolling(3).mean()` | Lissage de la tendance |
| `pct_change()` | Croissance mois sur mois |
| `df.corr(method="pearson")` | Matrice de corrélation |
| `stats.spearmanr()` | Corrélation robuste |
| `chi2_contingency()` | Association catégorielle |
| `V de Cramer` | Force de l'association |
| `pd.crosstab()` | Tableau de contingence |
| `dt.to_period("M")` | Analyse de cohortes |

---

*DataInsight Pro — Partie 3 | Prochaine étape -> Partie 4 : Visualisation Professionnelle*

# [GRAPHIQUE] DataInsight Pro — PARTIE 4
## Visualisation Professionnelle avec Matplotlib, Seaborn & Plotly

---

# 1⃣ CONTEXTE MÉTIER

> "Le rapport EDA est excellent, mais le COMEX ne lit pas les tableaux de chiffres. Ils ont besoin de GRAPHIQUES clairs, professionnels, et percutants. Je veux un dashboard visuel complet prêt pour la présentation."

---

# 2⃣ OBJECTIFS PÉDAGOGIQUES

[OK] Créer des graphiques Matplotlib professionnels (interface OO)  
[OK] Utiliser Seaborn pour les visualisations statistiques  
[OK] Créer des dashboards interactifs Plotly  
[OK] Sauvegarder en haute résolution (PNG 300dpi, PDF)  
[OK] Appliquer les principes de data visualization (choix du bon graphique)  

---

# 5⃣ IMPLÉMENTATION COMPLÈTE

## src/visualization.py

```python
# ============================================================
# src/visualization.py
# DataInsight Pro — Visualisations Professionnelles
# ============================================================
# Principe : chaque fonction retourne une Figure matplotlib
#            et ne fait PAS plt.show() (laissé au script appelant).
#            Cela permet de tester les fonctions en CI/CD.
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
import warnings
from pathlib import Path
from typing import Optional, List, Tuple
import sys

sys.path.insert(0, str(Path(__file__).parent))
from utils import log, FIGURES_DIR

warnings.filterwarnings("ignore")

# ─────────────────────────────────────────────────────────────
# CONFIGURATION GLOBALE DU STYLE
# ─────────────────────────────────────────────────────────────

def configurer_style():
    """
    Configure le style global des graphiques Matplotlib/Seaborn.
    À appeler une fois au début du script ou notebook.
    """
    # Palette de couleurs de marque (bleu, orange, vert)
    PALETTE_OLIST = ["#2196F3", "#FF9800", "#4CAF50",
                     "#9C27B0", "#F44336", "#00BCD4"]
    
    # Configuration Matplotlib
    plt.rcParams.update({
        "figure.facecolor":      "white",
        "axes.facecolor":        "#F8F9FA",   # Fond légèrement gris
        "axes.grid":             True,
        "grid.alpha":            0.3,
        "grid.color":            "#CCCCCC",
        "axes.spines.top":       False,        # Supprimer bordure haut
        "axes.spines.right":     False,        # Supprimer bordure droite
        "font.family":           "DejaVu Sans",
        "font.size":             11,
        "axes.labelsize":        12,
        "axes.titlesize":        13,
        "axes.titleweight":      "bold",
        "figure.titlesize":      15,
        "figure.titleweight":    "bold",
        "legend.framealpha":     0.9,
        "legend.fontsize":       10,
    })
    
    # Configuration Seaborn
    sns.set_palette(PALETTE_OLIST)
    sns.set_style("whitegrid")
    
    log("Style graphique configuré", "INFO")


# ─────────────────────────────────────────────────────────────
# GRAPHIQUE 1 : DISTRIBUTION DU CA PAR CATÉGORIE
# ─────────────────────────────────────────────────────────────

def plot_ca_par_categorie(
    df_categories: pd.DataFrame,
    top_n: int = 15,
    titre: str = "CA par Catégorie de Produit"
) -> plt.Figure:
    """
    Crée un graphique à barres horizontales du CA par catégorie.
    
    Choix du graphique :
    - Barres HORIZONTALES -> meilleur pour les noms longs
    - Triées -> facilite la comparaison
    - Annotation -> valeur au bout de chaque barre
    
    Args:
        df_categories : DataFrame issu de analyser_categories()
        top_n         : Nombre de catégories à afficher
        titre         : Titre du graphique
    
    Returns:
        Figure matplotlib
    """
    
    # Sélectionner les top N par CA
    df_plot = df_categories.head(top_n).copy()
    
    # Trier du plus petit au plus grand (pour l'affichage horizontal)
    # Les plus grandes valeurs seront en HAUT du graphique
    df_plot = df_plot.sort_values("ca_total", ascending=True)
    
    # ─── Créer la figure ──────────────────────────────────────────
    fig, ax = plt.subplots(figsize=(12, max(6, top_n * 0.5)))
    # figsize -> (largeur, hauteur) en pouces
    # max(6, top_n * 0.5) -> hauteur adaptative selon le nombre de catégories
    
    # ─── Barres horizontales ──────────────────────────────────────
    # barh -> horizontal bar chart
    barres = ax.barh(
        y=df_plot["categorie"],           # Axe Y = catégories
        width=df_plot["ca_total"],        # Largeur = CA total
        color="#2196F3",                  # Couleur bleu
        edgecolor="white",               # Bordure blanche
        linewidth=0.5,
        alpha=0.85
    )
    
    # ─── Colorer différemment le top 3 ───────────────────────────
    # Identifier les 3 dernières barres (top 3 du CA = en haut)
    for i, barre in enumerate(barres):
        if i >= top_n - 3:  # Les 3 dernières = top 3
            barre.set_color("#FF9800")  # Orange pour le top 3
    
    # ─── Annotations : valeur au bout de chaque barre ─────────────
    for barre in barres:
        largeur = barre.get_width()
        
        # ax.text -> écrire du texte sur le graphique
        # barre.get_width() + offset -> légèrement à droite de la barre
        # barre.get_y() + barre.get_height()/2 -> centré verticalement
        ax.text(
            largeur * 1.01,                    # Position X
            barre.get_y() + barre.get_height() / 2,  # Position Y
            f"R${largeur:,.0f}",              # Texte formaté
            va="center",                      # va = vertical alignment
            ha="left",                        # ha = horizontal alignment
            fontsize=9,
            color="#333333"
        )
    
    # ─── Mise en forme ────────────────────────────────────────────
    ax.set_title(titre, fontsize=14, fontweight="bold", pad=15)
    ax.set_xlabel("Chiffre d'Affaires (R$)", fontsize=12)
    ax.set_ylabel("")  # Pas de label pour l'axe Y (les noms suffisent)
    
    # Formatter l'axe X avec des milliers
    # FuncFormatter -> appliquer une fonction à chaque label de l'axe
    from matplotlib.ticker import FuncFormatter
    ax.xaxis.set_major_formatter(FuncFormatter(lambda x, _: f"R${x:,.0f}"))
    
    # Légende manuelle pour les couleurs
    from matplotlib.patches import Patch
    legende = [
        Patch(color="#FF9800", label="Top 3"),
        Patch(color="#2196F3", label="Autres catégories")
    ]
    ax.legend(handles=legende, loc="lower right", fontsize=10)
    
    plt.tight_layout()
    return fig


# ─────────────────────────────────────────────────────────────
# GRAPHIQUE 2 : ÉVOLUTION TEMPORELLE DU CA
# ─────────────────────────────────────────────────────────────

def plot_evolution_ca(df_mensuel: pd.DataFrame) -> plt.Figure:
    """
    Graphique en ligne de l'évolution mensuelle du CA.
    
    Inclut :
    - Ligne principale (CA mensuel brut)
    - Ligne lissée (moyenne mobile 3 mois)
    - Zone colorée selon la tendance (hausse/baisse)
    - Annotation du pic maximum
    
    Args:
        df_mensuel: DataFrame issu de analyser_evolution_temporelle()
    
    Returns:
        Figure matplotlib
    """
    
    fig, axes = plt.subplots(2, 1, figsize=(14, 9),
                              gridspec_kw={"height_ratios": [3, 1]})
    # gridspec_kw -> configuration de la grille
    # height_ratios=[3,1] -> graphique principal 3x plus grand que celui du bas
    
    # ─── Graphique 1 : CA mensuel ─────────────────────────────────
    ax1 = axes[0]
    
    x = df_mensuel["order_purchase_timestamp"]  # Dates
    y = df_mensuel["ca_mensuel"]               # CA mensuel
    
    # Ligne principale
    ax1.plot(x, y, color="#2196F3", linewidth=2, label="CA Mensuel", zorder=3)
    
    # Remplissage sous la courbe
    ax1.fill_between(x, y, alpha=0.15, color="#2196F3")
    
    # Moyenne mobile (si la colonne existe)
    if "ca_mm3" in df_mensuel.columns:
        ax1.plot(x, df_mensuel["ca_mm3"],
                 color="#FF9800", linewidth=2.5, linestyle="--",
                 label="Moy. Mobile 3 mois", zorder=4)
    
    # ─── Annoter le maximum ───────────────────────────────────────
    idx_max = y.idxmax()
    ax1.annotate(
        f"Pic : R${y[idx_max]:,.0f}\n{x[idx_max].strftime('%B %Y')}",
        xy=(x[idx_max], y[idx_max]),               # Point annoté
        xytext=(x[idx_max], y[idx_max] * 0.75),    # Position du texte
        fontsize=10,
        arrowprops=dict(arrowstyle="->", color="black", lw=1.5),
        bbox=dict(boxstyle="round,pad=0.3", facecolor="yellow", alpha=0.7)
    )
    
    ax1.set_title("Évolution Mensuelle du CA — Olist 2016-2018",
                  fontsize=14, fontweight="bold")
    ax1.set_ylabel("CA Mensuel (R$)")
    ax1.legend(loc="upper left")
    ax1.tick_params(axis="x", rotation=45)
    
    # ─── Graphique 2 : Croissance M/M ─────────────────────────────
    ax2 = axes[1]
    
    if "croissance_mm" in df_mensuel.columns:
        croissance = df_mensuel["croissance_mm"].fillna(0)
        
        # Barres colorées : vert si positif, rouge si négatif
        colors = ["#4CAF50" if v >= 0 else "#F44336" for v in croissance]
        
        ax2.bar(x, croissance, color=colors, alpha=0.7, width=20)
        ax2.axhline(0, color="black", linewidth=0.8)
        ax2.set_ylabel("Croissance M/M (%)")
        ax2.set_xlabel("Date")
        ax2.tick_params(axis="x", rotation=45)
    
    plt.tight_layout()
    return fig


# ─────────────────────────────────────────────────────────────
# GRAPHIQUE 3 : SATISFACTION CLIENT — DISTRIBUTION ET ANALYSE
# ─────────────────────────────────────────────────────────────

def plot_satisfaction(df: pd.DataFrame) -> plt.Figure:
    """
    Dashboard de satisfaction client sur 4 graphiques.
    
    1. Distribution des scores (barplot)
    2. Score moyen par catégorie (top 10)
    3. Score par délai de livraison (boxplot)
    4. Évolution mensuelle du score
    
    Args:
        df: DataFrame nettoyé
    
    Returns:
        Figure matplotlib 2×2
    """
    
    fig, axes = plt.subplots(2, 2, figsize=(16, 12))
    fig.suptitle("Analyse de la Satisfaction Client — Olist",
                 fontsize=16, fontweight="bold", y=1.01)
    
    # ─── Graphique 1 : Distribution des scores ────────────────────
    ax1 = axes[0][0]
    
    if "review_score" in df.columns:
        scores = df["review_score"].astype("float").dropna()
        vc = scores.value_counts().sort_index()
        pct = vc / len(scores) * 100
        
        # Barplot avec couleurs
        colors = ["#F44336", "#FF9800", "#FFC107", "#8BC34A", "#4CAF50"]
        bars = ax1.bar(
            vc.index, pct.values,
            color=colors[:len(vc)],
            edgecolor="white", linewidth=0.5
        )
        
        # Annotations
        for bar, val in zip(bars, pct.values):
            ax1.text(bar.get_x() + bar.get_width()/2,
                     bar.get_height() + 0.5,
                     f"{val:.1f}%",
                     ha="center", va="bottom", fontweight="bold", fontsize=10)
        
        ax1.set_title("Distribution des Scores d'Avis")
        ax1.set_xlabel("Score (1 = Très mauvais, 5 = Excellent)")
        ax1.set_ylabel("Proportion (%)")
        ax1.set_xticks([1, 2, 3, 4, 5])
        
        # Ajouter la moyenne
        moy = scores.mean()
        ax1.axvline(moy, color="navy", linestyle="--", linewidth=2)
        ax1.text(moy + 0.05, ax1.get_ylim()[1] * 0.9,
                 f"Moyenne\n{moy:.2f}", color="navy", fontsize=9)
    
    # ─── Graphique 2 : Score par catégorie ────────────────────────
    ax2 = axes[0][1]
    
    col_cat = next((c for c in ["category_english", "product_category_name_clean"]
                    if c in df.columns), None)
    
    if col_cat and "review_score" in df.columns:
        score_cat = (
            df.groupby(col_cat)["review_score"]
            .apply(lambda x: x.astype("float").mean())
            .sort_values(ascending=True)
            .tail(10)  # Top 10
        )
        
        # Barplot horizontal avec gradient de couleur
        couleurs = plt.cm.RdYlGn(
            np.linspace(0.2, 0.9, len(score_cat))
        )
        
        ax2.barh(score_cat.index, score_cat.values,
                 color=couleurs, edgecolor="white")
        ax2.axvline(4, color="red", linestyle="--", alpha=0.5, label="Seuil 4/5")
        ax2.set_title("Score Moyen (Top 10 Catégories)")
        ax2.set_xlabel("Score Moyen")
        ax2.set_xlim(3, 5)
        ax2.legend()
    
    # ─── Graphique 3 : Score vs Délai (Boxplot) ───────────────────
    ax3 = axes[1][0]
    
    if "categorie_delai" in df.columns and "review_score" in df.columns:
        df_box = df[df["categorie_delai"].notna()].copy()
        df_box["score_float"] = df_box["review_score"].astype("float")
        
        sns.boxplot(
            data=df_box,
            x="categorie_delai",
            y="score_float",
            palette="RdYlGn",
            ax=ax3,
            order=["Express (<7j)", "Rapide (7-14j)",
                   "Standard (14-21j)", "Long (21-30j)", "Très long (>30j)"]
        )
        ax3.set_title("Score selon la Catégorie de Délai")
        ax3.set_xlabel("Délai de Livraison")
        ax3.set_ylabel("Score d'Avis")
        ax3.tick_params(axis="x", rotation=30)
        
        # Ligne de score moyen global
        ax3.axhline(df_box["score_float"].mean(),
                    color="navy", linestyle="--", linewidth=1.5,
                    label=f"Moy. globale: {df_box['score_float'].mean():.2f}")
        ax3.legend()
    
    # ─── Graphique 4 : Évolution mensuelle du score ───────────────
    ax4 = axes[1][1]
    
    if "order_purchase_timestamp" in df.columns and "review_score" in df.columns:
        df_ts = df.set_index("order_purchase_timestamp")
        df_ts["score_float"] = df_ts["review_score"].astype("float")
        
        score_mensuel = df_ts["score_float"].resample("ME").mean()
        
        ax4.plot(score_mensuel.index, score_mensuel.values,
                 "o-", color="#4CAF50", linewidth=2, markersize=5,
                 label="Score mensuel")
        
        # Zone de satisfaction (score ≥ 4)
        ax4.axhspan(4, 5, alpha=0.1, color="#4CAF50", label="Zone satisfait (≥4)")
        ax4.axhspan(1, 3, alpha=0.1, color="#F44336", label="Zone insatisfait (≤3)")
        ax4.axhline(4, color="#4CAF50", linestyle="--", alpha=0.5)
        ax4.axhline(3, color="#F44336", linestyle="--", alpha=0.5)
        
        ax4.set_ylim(1, 5)
        ax4.set_title("Évolution Mensuelle du Score Moyen")
        ax4.set_ylabel("Score Moyen (/5)")
        ax4.set_xlabel("Date")
        ax4.legend(fontsize=8)
        ax4.tick_params(axis="x", rotation=45)
    
    plt.tight_layout()
    return fig


# ─────────────────────────────────────────────────────────────
# GRAPHIQUE 4 : HEATMAP GÉOGRAPHIQUE (CARTE BRÉSILIENNE)
# ─────────────────────────────────────────────────────────────

def plot_heatmap_etats(df_etats: pd.DataFrame) -> plt.Figure:
    """
    Heatmap du CA par État brésilien.
    
    Présente les données géographiques sous forme de heatmap
    (alternative à une carte choroplèthe sans bibliothèques spécialisées).
    
    Args:
        df_etats: DataFrame issu de analyser_etats()
    
    Returns:
        Figure matplotlib
    """
    
    fig, axes = plt.subplots(1, 2, figsize=(16, 8))
    
    # ─── Graphique 1 : CA par État ─────────────────────────────────
    ax1 = axes[0]
    
    df_plot = df_etats.sort_values("ca_total", ascending=True)
    
    # Gradient de couleur proportionnel au CA
    normed = (df_plot["ca_total"] - df_plot["ca_total"].min()) / \
             (df_plot["ca_total"].max() - df_plot["ca_total"].min())
    colors = plt.cm.Blues(0.3 + normed * 0.7)  # Blues : 30% à 100%
    
    ax1.barh(df_plot["etat"], df_plot["ca_total"],
             color=colors, edgecolor="white", linewidth=0.3)
    
    ax1.set_title("CA Total par État Brésilien", fontweight="bold")
    ax1.set_xlabel("CA Total (R$)")
    
    # ─── Graphique 2 : Heatmap score × délai par État ─────────────
    ax2 = axes[1]
    
    # Créer une petite matrice pour la heatmap
    top_etats = df_etats.head(10)[["etat", "score_moyen", "delai_moyen"]].copy()
    top_etats = top_etats.set_index("etat")
    top_etats = top_etats.rename(columns={
        "score_moyen": "Score (/5)",
        "delai_moyen": "Délai (j)"
    })
    
    # Normaliser pour la comparaison visuelle
    top_etats_norm = (top_etats - top_etats.min()) / (top_etats.max() - top_etats.min())
    
    sns.heatmap(
        top_etats_norm,
        annot=top_etats.round(1),  # Afficher les valeurs originales
        fmt="g",
        cmap="RdYlGn",
        linewidths=0.5,
        cbar_kws={"label": "Score normalisé (0-1)"},
        ax=ax2
    )
    ax2.set_title("Score vs Délai — Top 10 États\n"
                  "(Vert = meilleur | Rouge = moins bon)",
                  fontweight="bold")
    ax2.set_xlabel("")
    
    plt.suptitle("Analyse Géographique — Performance par État",
                 fontsize=14, fontweight="bold")
    plt.tight_layout()
    return fig


# ─────────────────────────────────────────────────────────────
# GRAPHIQUE 5 : DASHBOARD PRINCIPAL (12 GRAPHIQUES)
# ─────────────────────────────────────────────────────────────

def plot_dashboard_complet(df: pd.DataFrame, kpis: dict) -> plt.Figure:
    """
    Crée le dashboard principal avec 12 graphiques.
    
    Utilise GridSpec pour une mise en page complexe :
    - Ligne 1 : 4 KPI cards (métriques clés)
    - Ligne 2-3 : 4 graphiques principaux
    - Ligne 4 : 4 graphiques secondaires
    
    Args:
        df  : DataFrame nettoyé
        kpis: Dictionnaire des KPIs calculés
    
    Returns:
        Figure matplotlib complexe
    """
    
    fig = plt.figure(figsize=(20, 16))
    gs = gridspec.GridSpec(3, 4, figure=fig, hspace=0.4, wspace=0.3)
    
    fig.suptitle("DataInsight Pro — Dashboard Olist E-Commerce",
                 fontsize=18, fontweight="bold", y=0.98)
    
    # ─── Ligne 1 : KPI Cards ──────────────────────────────────────
    kpi_cards = [
        ("CA Total", kpis.get("ca_total", 0), "R$", "#2196F3"),
        ("Commandes", kpis.get("n_commandes", 0), "", "#4CAF50"),
        ("Score Moyen", kpis.get("score_moyen", 0), "/5", "#FF9800"),
        ("Délai Moyen", kpis.get("delai_moyen_jours", 0), "j", "#9C27B0"),
    ]
    
    for i, (label, valeur, unite, couleur) in enumerate(kpi_cards):
        ax_kpi = fig.add_subplot(gs[0, i])
        ax_kpi.set_facecolor(couleur)
        ax_kpi.text(0.5, 0.6, f"{valeur:,.1f}{unite}", ha="center", va="center",
                    fontsize=20, fontweight="bold", color="white",
                    transform=ax_kpi.transAxes)
        ax_kpi.text(0.5, 0.2, label, ha="center", va="center",
                    fontsize=12, color="white", transform=ax_kpi.transAxes)
        ax_kpi.set_xticks([])
        ax_kpi.set_yticks([])
        for spine in ax_kpi.spines.values():
            spine.set_visible(False)
    
    # ─── Ligne 2 : Graphiques principaux ──────────────────────────
    
    # CA mensuel (2 colonnes)
    ax_ts = fig.add_subplot(gs[1, 0:2])
    if "order_purchase_timestamp" in df.columns and "price" in df.columns:
        df_ts = df.set_index("order_purchase_timestamp")["price"]
        ca_mensuel = df_ts.resample("ME").sum()
        ax_ts.plot(ca_mensuel.index, ca_mensuel.values,
                   "o-", color="#2196F3", linewidth=2, markersize=3)
        ax_ts.fill_between(ca_mensuel.index, ca_mensuel.values, alpha=0.2)
        ax_ts.set_title("Évolution CA Mensuel")
        ax_ts.tick_params(axis="x", rotation=45)
    
    # Distribution des scores (1 colonne)
    ax_score = fig.add_subplot(gs[1, 2])
    if "review_score" in df.columns:
        vc = df["review_score"].astype("float").value_counts().sort_index()
        ax_score.bar(vc.index, vc.values / len(df) * 100,
                     color=["#F44336", "#FF9800", "#FFC107", "#8BC34A", "#4CAF50"])
        ax_score.set_title("Distribution Scores")
        ax_score.set_xlabel("Score")
        ax_score.set_ylabel("%")
    
    # Retards (1 colonne)
    ax_retard = fig.add_subplot(gs[1, 3])
    if "est_en_retard" in df.columns:
        livrees = df[df["est_livree"] == True] if "est_livree" in df.columns else df
        taux_retard = livrees["est_en_retard"].mean() * 100
        taux_ok = 100 - taux_retard
        ax_retard.pie(
            [taux_ok, taux_retard],
            labels=[f"À temps\n{taux_ok:.1f}%", f"Retard\n{taux_retard:.1f}%"],
            colors=["#4CAF50", "#F44336"],
            startangle=90,
            wedgeprops={"edgecolor": "white", "linewidth": 2}
        )
        ax_retard.set_title("Taux de Retard")
    
    # ─── Ligne 3 : Graphiques secondaires ─────────────────────────
    
    # CA par État (1 colonne)
    ax_etat = fig.add_subplot(gs[2, 0])
    if "customer_state" in df.columns and "price" in df.columns:
        ca_etat = (df.groupby("customer_state")["price"].sum()
                   .sort_values(ascending=False).head(8))
        ca_etat.plot(kind="barh", ax=ax_etat, color="#2196F3")
        ax_etat.set_title("Top 8 États (CA)")
    
    # Distribution des délais (1 colonne)
    ax_delai = fig.add_subplot(gs[2, 1])
    if "delai_livraison_jours" in df.columns:
        delais = df["delai_livraison_jours"].dropna()
        ax_delai.hist(delais, bins=40, color="#9C27B0", edgecolor="white", alpha=0.8)
        ax_delai.axvline(delais.median(), color="red", linestyle="--",
                         label=f"Médiane: {delais.median():.0f}j")
        ax_delai.set_title("Distribution des Délais")
        ax_delai.set_xlabel("Jours")
        ax_delai.legend()
    
    # Commandes par heure (1 colonne)
    ax_heure = fig.add_subplot(gs[2, 2])
    if "heure_commande" in df.columns:
        par_heure = df.groupby("heure_commande")["order_id"].nunique()
        ax_heure.bar(par_heure.index, par_heure.values, color="#FF9800", alpha=0.8)
        ax_heure.set_title("Commandes par Heure")
        ax_heure.set_xlabel("Heure")
    
    # Top catégories (1 colonne)
    ax_cat = fig.add_subplot(gs[2, 3])
    col_cat = next((c for c in ["category_english", "product_category_name_clean"]
                    if c in df.columns), None)
    if col_cat:
        top_cats = (df.groupby(col_cat)["price"].sum()
                    .sort_values(ascending=True).tail(6))
        top_cats.plot(kind="barh", ax=ax_cat, color="#4CAF50")
        ax_cat.set_title("Top Catégories")
    
    return fig


# ─────────────────────────────────────────────────────────────
# FONCTION UTILITAIRE : SAUVEGARDER ET AFFICHER
# ─────────────────────────────────────────────────────────────

def sauvegarder_figure(fig: plt.Figure, nom: str, dpi: int = 150) -> Path:
    """
    Sauvegarde une figure matplotlib dans le dossier reports/figures/.
    
    Args:
        fig : Figure matplotlib à sauvegarder
        nom : Nom du fichier (sans extension)
        dpi : Résolution (150 = web, 300 = impression)
    
    Returns:
        Chemin du fichier créé
    """
    FIGURES_DIR.mkdir(parents=True, exist_ok=True)
    
    chemin_png = FIGURES_DIR / f"{nom}.png"
    
    # bbox_inches="tight" -> couper les marges blanches
    # facecolor="white" -> fond blanc (pas transparent)
    fig.savefig(chemin_png, dpi=dpi, bbox_inches="tight", facecolor="white")
    
    taille_kb = chemin_png.stat().st_size / 1024
    log(f"Figure sauvegardée : {chemin_png.name} ({taille_kb:.0f} KB)", "SUCCESS")
    
    return chemin_png


# ─────────────────────────────────────────────────────────────
# POINT D'ENTRÉE
# ─────────────────────────────────────────────────────────────

if __name__ == "__main__":
    import sys
    sys.path.insert(0, "src")
    from analysis import calculer_kpis_globaux, analyser_categories, analyser_etats
    from analysis import analyser_evolution_temporelle
    
    # Charger les données
    df = pd.read_parquet("data/processed/orders_clean.parquet")
    
    # Configurer le style
    configurer_style()
    
    # Calculer les métriques
    kpis = calculer_kpis_globaux(df)
    df_cats = analyser_categories(df, top_n=15)
    df_etats = analyser_etats(df)
    resultats_ts = analyser_evolution_temporelle(df)
    
    # Générer et sauvegarder les graphiques
    print("\n[1] Dashboard complet...")
    fig_dashboard = plot_dashboard_complet(df, kpis)
    sauvegarder_figure(fig_dashboard, "dashboard_complet", dpi=150)
    plt.show()
    plt.close()
    
    print("\n[2] CA par catégorie...")
    fig_cat = plot_ca_par_categorie(df_cats)
    sauvegarder_figure(fig_cat, "ca_par_categorie")
    plt.show()
    plt.close()
    
    print("\n[3] Évolution temporelle...")
    if "mensuel" in resultats_ts:
        fig_ts = plot_evolution_ca(resultats_ts["mensuel"])
        sauvegarder_figure(fig_ts, "evolution_ca")
        plt.show()
        plt.close()
    
    print("\n[4] Satisfaction client...")
    fig_sat = plot_satisfaction(df)
    sauvegarder_figure(fig_sat, "satisfaction_client")
    plt.show()
    plt.close()
    
    print("\n[5] Analyse géographique...")
    fig_geo = plot_heatmap_etats(df_etats)
    sauvegarder_figure(fig_geo, "analyse_geographique")
    plt.show()
    plt.close()
    
    print("\n[OK] Tous les graphiques générés dans reports/figures/")
```

---

# 6⃣ EXPLICATIONS CLÉS

## GridSpec — Mise en Page Avancée

```python
import matplotlib.gridspec as gridspec

# Créer une figure avec une grille personnalisée
fig = plt.figure(figsize=(20, 16))

# GridSpec(nrows, ncols) -> définit la grille
gs = gridspec.GridSpec(
    3, 4,              # 3 lignes, 4 colonnes
    figure=fig,
    hspace=0.4,        # Espace vertical entre graphiques
    wspace=0.3         # Espace horizontal
)

# Ajouter des subplots de différentes tailles
ax_grand  = fig.add_subplot(gs[0, 0:2])  # Ligne 0, colonnes 0-1 (large)
ax_moyen1 = fig.add_subplot(gs[0, 2])    # Ligne 0, colonne 2
ax_moyen2 = fig.add_subplot(gs[0, 3])    # Ligne 0, colonne 3
ax_bas    = fig.add_subplot(gs[1:3, :])  # Lignes 1-2, toutes colonnes
```

## Choix du Bon Graphique

```
COMPARER des groupes       -> Barplot, Boxplot, Violinplot
MONTRER la distribution    -> Histogramme, KDE, Violinplot
MONTRER une évolution      -> Ligne, Aire
MONTRER la composition     -> Camembert (<5 parts), Barres empilées
MONTRER une corrélation    -> Scatter, Heatmap, Pairplot
MONTRER une géographie     -> Choroplèthe, Scatter Map
```

---

# [10] EXERCICES

## [VERT] FACILE
**Ex 1 :** Créez un barplot horizontal des 10 vendeurs avec le plus de commandes.
Colorez en vert ceux avec un score ≥ 4, en rouge les autres.

## [JAUNE] INTERMÉDIAIRE
**Ex 2 :** Créez un violinplot du délai de livraison par État (top 10 États).
Superposez les points individuels avec `sns.stripplot`.

## [ROUGE] AVANCÉ
**Ex 3 :** Créez un rapport HTML automatique qui inclut tous les graphiques
avec du texte d'explication entre chaque graphique.
Utiliser `matplotlib` + HTML basique + `base64` pour encoder les images.

---

*DataInsight Pro — Partie 4 | Prochaine étape -> Partie 5 : Analyse Avancée*

# [GRAPHIQUE] DataInsight Pro — Partie 5
## Analyse Avancée : Segmentation Clients, Vendeurs, Détection d'Anomalies

**Dataset : Olist Brazilian E-Commerce** (https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce)
**Fichier source :** `data/processed/olist_master.parquet` (créé en Partie 1)

---

## 1⃣ Contexte Métier Réel

**Entreprise :** Olist — marketplace e-commerce brésilienne
**Problématique :** Après le nettoyage et l'EDA des parties précédentes, l'équipe Data demande trois analyses avancées :

1. **Segmentation RFM des clients** : Identifier les segments (Champions, Fidèles, À risque, Perdus) pour cibler les campagnes marketing
2. **Analyse de performance des vendeurs** : Classer les vendeurs selon un score composite (CA, délais, satisfaction)
3. **Détection d'anomalies** : Repérer les commandes et paiements suspects

---

## 2⃣ Objectifs Pédagogiques

| Compétence | Concept Python/Pandas |
|---|---|
| Segmentation RFM | `groupby`, `cut`, `crosstab`, `qcut` |
| Scoring composite | `rank()`, normalisation min-max, `apply` |
| Détection anomalies | `IsolationForest`, Z-score, règles métier |
| Visualisation avancée | Heatmaps, scatter coloré, treemap |
| Analyse cohorte | `to_period()`, `pivot_table`, `heatmap` |

---

## 3⃣ Énoncé Détaillé

### Tâche A — Analyse RFM
- Calculer Recency, Frequency, Monetary pour chaque client
- Attribuer des scores 1–5 par quintiles
- Créer 6 segments business : Champion, Client Fidèle, Potentiel, Nouveau, À Risque, Perdu
- Visualiser la distribution des segments et leur valeur moyenne

### Tâche B — Score Vendeurs
- Calculer 5 KPIs par vendeur : CA total, nb commandes, note moyenne, taux de livraison dans les délais, taux retour
- Normaliser et pondérer pour créer un `score_global`
- Classer en 4 tiers : Platine, Or, Argent, Bronze
- Identifier les top 10 et bottom 10 vendeurs

### Tâche C — Détection d'Anomalies
- Détecter les commandes avec prix aberrant (Z-score > 3)
- Détecter les frais de livraison anormaux (IQR)
- Isolation Forest sur les paiements (montant × nb_versements × type)
- Règles métier : commandes livrées avant la date estimée de plus de 10 jours

---

## 4⃣ Théorie Appliquée

### Analyse RFM — Modèle de Segmentation Client

L'analyse **RFM** (Recency, Frequency, Monetary) est un modèle classique du marketing direct datant des années 1990, toujours massivement utilisé en e-commerce.

```
R (Recency)   = Nombre de jours depuis la dernière commande
                -> Un client récent est plus susceptible d'acheter à nouveau
                -> Score : 5=très récent, 1=très ancien

F (Frequency) = Nombre de commandes passées sur la période
                -> Un client fréquent est fidèle
                -> Score : 5=très fréquent, 1=rare

M (Monetary)  = Montant total dépensé sur la période
                -> Un client à valeur élevée mérite attention
                -> Score : 5=gros dépensier, 1=faible valeur
```

**Matrice de segmentation :**
```
Score RF  │ Description        │ Action marketing
──────────┼───────────────────┼───────────────────────────
55, 54    │ Champion           │ Récompenser, demander avis
44, 45    │ Client Fidèle      │ Programme de fidélité
33-34,43  │ Potentiel          │ Email personnalisé
41,51     │ Nouveau Client     │ Onboarding, 1er achat
25,24     │ À Risque           │ Réactivation urgente
11, 12    │ Perdu              │ Campagne win-back
```

### Scoring Composite — Normalisation Min-Max

Pour agréger des KPIs d'unités différentes (€, %, jours), on normalise chaque indicateur dans [0, 1] :

```
score_normalise = (valeur - min) / (max - min)
```

Puis on applique des **pondérations métier** selon l'importance de chaque critère :

```python
score_global = (0.35 * score_ca + 0.25 * score_satisfaction +
                0.20 * score_ponctualite + 0.15 * score_volume +
                0.05 * score_anciennete)
```

---

## 5⃣ Implémentation Complète

### Fichier : `src/advanced_analysis.py`

```python
# ============================================================
# src/advanced_analysis.py
# Analyse avancée : RFM, Scoring Vendeurs, Anomalies
# Projet : DataInsight Pro — Dataset Olist
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from pathlib import Path
from datetime import datetime
from sklearn.ensemble import IsolationForest
from sklearn.preprocessing import MinMaxScaler
from scipy import stats
import warnings
warnings.filterwarnings("ignore")

# Imports internes
import sys
sys.path.insert(0, str(Path(__file__).parent))
from utils import ROOT_DIR, DATA_PROCESSED_DIR, REPORTS_DIR, timeit, log

# ─────────────────────────────────────────────────────────────
# CONSTANTES
# ─────────────────────────────────────────────────────────────

FIGURES_DIR = REPORTS_DIR / "figures"
FIGURES_DIR.mkdir(parents=True, exist_ok=True)

# Poids pour le score vendeur (doivent sommer à 1.0)
POIDS_VENDEUR = {
    "score_ca":           0.35,   # Chiffre d'affaires : facteur principal
    "score_satisfaction": 0.25,   # Satisfaction client : critique pour réputation
    "score_ponctualite":  0.20,   # Ponctualité des livraisons : confiance client
    "score_volume":       0.15,   # Volume de commandes : stabilité business
    "score_anciennete":   0.05,   # Ancienneté : expérience de la plateforme
}

# Couleurs des segments RFM
COULEURS_SEGMENTS = {
    "Champion":       "#2ECC71",  # Vert vif
    "Client Fidèle":  "#27AE60",  # Vert foncé
    "Potentiel":      "#F39C12",  # Orange
    "Nouveau Client": "#3498DB",  # Bleu
    "À Risque":       "#E67E22",  # Orange foncé
    "Perdu":          "#E74C3C",  # Rouge
}


# ─────────────────────────────────────────────────────────────
# SECTION A : ANALYSE RFM
# ─────────────────────────────────────────────────────────────

class AnalyseRFM:
    """
    Effectue une analyse RFM complète sur les commandes Olist.

    L'analyse RFM (Recency, Frequency, Monetary) segmente les clients
    selon leur comportement d'achat pour orienter les actions marketing.
    """

    def __init__(self, df: pd.DataFrame):
        """
        Args:
            df: DataFrame maître avec au minimum les colonnes :
                customer_unique_id, order_purchase_timestamp, price, freight_value
        """
        self.df = df.copy()
        self.rfm = None          # DataFrame RFM calculé
        self.segments = None     # DataFrame avec segments assignés

    # ─── Étape 1 : Calculer les métriques RFM ─────────────────
    @timeit
    def calculer_rfm(self) -> pd.DataFrame:
        """
        Calcule les 3 métriques RFM pour chaque client unique.

        Returns:
            DataFrame avec colonnes : customer_unique_id, recency, frequency, monetary
        """
        log("Calcul des métriques RFM...")

        # ── Filtrer les commandes livrées uniquement ──────────
        # On ne comptabilise que les vraies transactions finalisées
        df_livrees = self.df[self.df["order_status"] == "delivered"].copy()

        # Convertir la date de commande en datetime si ce n'est pas fait
        # pd.to_datetime : convertit string -> datetime64
        if df_livrees["order_purchase_timestamp"].dtype == "object":
            df_livrees["order_purchase_timestamp"] = pd.to_datetime(
                df_livrees["order_purchase_timestamp"]
            )

        # ── Date de référence : lendemain de la dernière commande ──
        # Pourquoi "lendemain" ? Pour que le client le plus récent ait recency=1
        date_ref = df_livrees["order_purchase_timestamp"].max() + pd.Timedelta(days=1)
        log(f"Date de référence : {date_ref.date()}")

        # ── Montant par commande ──────────────────────────────
        # On crée le montant total s'il n'existe pas
        if "montant_total" not in df_livrees.columns:
            df_livrees["montant_total"] = df_livrees["price"] + df_livrees["freight_value"]

        # ── Agrégation par client unique ──────────────────────
        # groupby("customer_unique_id") : regrouper toutes les commandes du même client
        # agg() : calculer plusieurs statistiques en une seule opération
        rfm = df_livrees.groupby("customer_unique_id").agg(
            recency   = ("order_purchase_timestamp",
                          # lambda x : fonction anonyme
                          # (date_ref - x.max()) : écart entre ref et dernière commande
                          # .days : extraire le nombre de jours
                          lambda x: (date_ref - x.max()).days),
            frequency = ("order_id", "nunique"),      # Nb de commandes distinctes
            monetary  = ("montant_total", "sum"),     # Total dépensé
        ).reset_index()
        # reset_index() : transformer l'index groupby en colonne normale

        # ── Vérification des valeurs ──────────────────────────
        assert (rfm["recency"] >= 0).all(),   "Recency négative détectée !"
        assert (rfm["frequency"] >= 1).all(), "Frequency nulle détectée !"
        assert (rfm["monetary"] >= 0).all(),  "Monetary négative détectée !"

        log(f"RFM calculé : {len(rfm):,} clients uniques")
        log(f"Recency  : min={rfm['recency'].min()}j, max={rfm['recency'].max()}j, "
            f"moy={rfm['recency'].mean():.0f}j")
        log(f"Frequency: min={rfm['frequency'].min()}, max={rfm['frequency'].max()}, "
            f"moy={rfm['frequency'].mean():.2f}")
        log(f"Monetary : min={rfm['monetary'].min():.0f}R$, "
            f"max={rfm['monetary'].max():.0f}R$, moy={rfm['monetary'].mean():.0f}R$")

        self.rfm = rfm
        return rfm

    # ─── Étape 2 : Attribuer les scores 1-5 ───────────────────
    def scorer_rfm(self) -> pd.DataFrame:
        """
        Attribue des scores de 1 à 5 à chaque client pour R, F, M.

        Méthode : quintiles (qcut) -> chaque score contient ~20% des clients

        ATTENTION pour Recency : score inversé !
        - Recency élevé (vieux client) -> score bas (1)
        - Recency faible (client récent) -> score haut (5)
        """
        if self.rfm is None:
            raise ValueError("Appeler d'abord calculer_rfm()")

        rfm = self.rfm.copy()

        # ── Score Recency (inversé) ───────────────────────────
        # pd.qcut : divise en quantiles de même effectif
        # labels=[5,4,3,2,1] : inversé car recency faible = bon
        # duplicates="drop" : gérer les valeurs identiques aux bornes
        rfm["score_r"] = pd.qcut(
            rfm["recency"],
            q=5,
            labels=[5, 4, 3, 2, 1],  # Inversé !
            duplicates="drop"
        ).astype(int)

        # ── Score Frequency ────────────────────────────────────
        # Ici les labels sont dans l'ordre normal (1=peu fréquent, 5=très fréquent)
        # Mais sur Olist la plupart des clients n'ont qu'une commande !
        # On utilise rank() + cut pour mieux gérer cette distribution
        rfm["score_f"] = pd.qcut(
            rfm["frequency"].rank(method="first"),  # rank() pour gérer les ex-aequo
            q=5,
            labels=[1, 2, 3, 4, 5],
            duplicates="drop"
        ).astype(int)

        # ── Score Monetary ─────────────────────────────────────
        rfm["score_m"] = pd.qcut(
            rfm["monetary"],
            q=5,
            labels=[1, 2, 3, 4, 5],
            duplicates="drop"
        ).astype(int)

        # ── Score RFM combiné ─────────────────────────────────
        # Concaténer les 3 scores en un code string : "555", "321"...
        rfm["rfm_score"] = (
            rfm["score_r"].astype(str) +
            rfm["score_f"].astype(str) +
            rfm["score_m"].astype(str)
        )

        # ── Score numérique moyen ─────────────────────────────
        rfm["rfm_moyen"] = (rfm["score_r"] + rfm["score_f"] + rfm["score_m"]) / 3

        log(f"Scores RFM attribués. Distribution score_r :\n{rfm['score_r'].value_counts().sort_index()}")

        self.rfm = rfm
        return rfm

    # ─── Étape 3 : Assigner les segments ──────────────────────
    def segmenter(self) -> pd.DataFrame:
        """
        Assigne un segment business à chaque client basé sur ses scores RFM.

        Règles de segmentation (adaptées à Olist) :
        - Champion       : clients récents, fréquents, gros acheteurs
        - Client Fidèle  : clients fréquents avec bon score global
        - Potentiel      : clients à bon potentiel mais pas encore fidèles
        - Nouveau Client : clients très récents, peu d'historique
        - À Risque       : anciens bons clients qui n'achètent plus
        - Perdu          : clients inactifs avec peu d'historique
        """
        if "score_r" not in self.rfm.columns:
            raise ValueError("Appeler d'abord scorer_rfm()")

        rfm = self.rfm.copy()
        r = rfm["score_r"]  # Alias pour lisibilité
        f = rfm["score_f"]
        m = rfm["score_m"]

        # np.select : équivalent vectorisé de if/elif/else
        # conditions : liste de masques booléens dans l'ordre de priorité
        # choices    : valeur correspondante si la condition est vraie
        conditions = [
            (r >= 4) & (f >= 4) & (m >= 4),                  # Champion
            (f >= 3) & (m >= 3) & (r >= 3),                   # Client Fidèle
            (r >= 3) & ((f >= 2) | (m >= 3)),                  # Potentiel
            (r >= 4) & (f <= 2),                               # Nouveau Client
            (r <= 2) & (f >= 3) & (m >= 3),                   # À Risque
        ]
        choices = [
            "Champion", "Client Fidèle", "Potentiel",
            "Nouveau Client", "À Risque"
        ]

        rfm["segment"] = np.select(
            conditions,
            choices,
            default="Perdu"   # Tous les cas non couverts -> Perdu
        )

        # Statistiques par segment
        stats_segments = rfm.groupby("segment").agg(
            nb_clients      = ("customer_unique_id", "count"),
            recency_moy     = ("recency", "mean"),
            frequency_moy   = ("frequency", "mean"),
            monetary_moy    = ("monetary", "mean"),
            rfm_moy         = ("rfm_moyen", "mean"),
        ).round(2)

        log(f"\nDistribution des segments :")
        log(stats_segments.to_string())

        self.rfm = rfm
        self.segments = stats_segments
        return rfm

    # ─── Étape 4 : Visualisation ──────────────────────────────
    def visualiser(self) -> None:
        """Crée un dashboard RFM complet."""
        if self.rfm is None:
            raise ValueError("Lancer d'abord calculer_rfm() -> scorer_rfm() -> segmenter()")

        rfm = self.rfm
        fig = plt.figure(figsize=(20, 14))
        fig.suptitle("Analyse RFM — Olist E-Commerce", fontsize=16, fontweight="bold")

        gs = gridspec.GridSpec(3, 3, figure=fig, hspace=0.45, wspace=0.35)

        # 1. Distribution des segments (donut)
        ax1 = fig.add_subplot(gs[0, 0])
        seg_counts = rfm["segment"].value_counts()
        colors = [COULEURS_SEGMENTS.get(s, "#95A5A6") for s in seg_counts.index]
        wedges, texts, autotexts = ax1.pie(
            seg_counts.values,
            labels=seg_counts.index,
            colors=colors,
            autopct="%1.1f%%",
            startangle=90,
            pctdistance=0.82,
            wedgeprops={"linewidth": 2, "edgecolor": "white"},
        )
        # Ajouter un cercle au centre pour créer l'effet donut
        centre_cercle = plt.Circle((0, 0), 0.65, fc="white")
        ax1.add_patch(centre_cercle)
        ax1.set_title("Distribution des segments", fontweight="bold")

        # 2. CA moyen par segment
        ax2 = fig.add_subplot(gs[0, 1])
        ca_par_segment = rfm.groupby("segment")["monetary"].mean().sort_values(ascending=True)
        colors_bar = [COULEURS_SEGMENTS.get(s, "#95A5A6") for s in ca_par_segment.index]
        bars = ax2.barh(ca_par_segment.index, ca_par_segment.values,
                        color=colors_bar, edgecolor="white", linewidth=0.5)
        # Annoter les barres avec les valeurs
        for bar, val in zip(bars, ca_par_segment.values):
            ax2.text(bar.get_width() + 5, bar.get_y() + bar.get_height() / 2,
                     f"R${val:,.0f}", va="center", fontsize=8)
        ax2.set_title("CA moyen par segment (R$)", fontweight="bold")
        ax2.set_xlabel("Montant moyen (R$)")

        # 3. Scatter RFM coloré par segment
        ax3 = fig.add_subplot(gs[0, 2])
        for segment, group in rfm.groupby("segment"):
            ax3.scatter(
                group["frequency"],
                group["monetary"],
                label=segment,
                color=COULEURS_SEGMENTS.get(segment, "#95A5A6"),
                alpha=0.4,
                s=20,
            )
        ax3.set_xlabel("Fréquence (nb commandes)")
        ax3.set_ylabel("Monetary (R$)")
        ax3.set_title("Fréquence vs Monetary par segment", fontweight="bold")
        ax3.legend(fontsize=7, loc="upper right")
        ax3.set_yscale("log")  # Echelle log car monetary est très asymétrique

        # 4. Distribution de la Recency
        ax4 = fig.add_subplot(gs[1, 0])
        ax4.hist(rfm["recency"], bins=60, color="#3498DB", edgecolor="white", alpha=0.7)
        ax4.axvline(rfm["recency"].median(), color="red", linestyle="--",
                    label=f"Médiane={rfm['recency'].median():.0f}j")
        ax4.set_xlabel("Jours depuis dernier achat")
        ax4.set_ylabel("Nb clients")
        ax4.set_title("Distribution de la Recency", fontweight="bold")
        ax4.legend()

        # 5. Distribution de la Frequency
        ax5 = fig.add_subplot(gs[1, 1])
        freq_counts = rfm["frequency"].value_counts().sort_index()
        ax5.bar(freq_counts.index[:10], freq_counts.values[:10],
                color="#9B59B6", edgecolor="white")
        ax5.set_xlabel("Nombre de commandes")
        ax5.set_ylabel("Nb clients")
        ax5.set_title("Distribution de la Fréquence", fontweight="bold")
        # Annotation : % de clients avec 1 seule commande
        pct_1 = (rfm["frequency"] == 1).mean() * 100
        ax5.text(0.65, 0.85, f"{pct_1:.1f}% ont 1 seule\ncommande",
                 transform=ax5.transAxes, bbox=dict(boxstyle="round", fc="yellow", alpha=0.5))

        # 6. Heatmap RFM Score R vs Score F
        ax6 = fig.add_subplot(gs[1, 2])
        rfm_pivot = rfm.groupby(["score_r", "score_f"])["monetary"].mean().unstack()
        sns.heatmap(
            rfm_pivot,
            annot=True,
            fmt=".0f",
            cmap="YlOrRd",
            ax=ax6,
            linewidths=0.5,
            cbar_kws={"label": "CA moyen (R$)"},
        )
        ax6.set_title("CA moyen : Score R × Score F", fontweight="bold")
        ax6.set_xlabel("Score Frequency")
        ax6.set_ylabel("Score Recency")

        # 7. Tableau récapitulatif des segments
        ax7 = fig.add_subplot(gs[2, :])
        ax7.axis("off")
        stats_tab = rfm.groupby("segment").agg(
            Clients       = ("customer_unique_id", "count"),
            Pct           = ("customer_unique_id", lambda x: f"{len(x)/len(rfm)*100:.1f}%"),
            Recency_moy   = ("recency",   lambda x: f"{x.mean():.0f}j"),
            Freq_moy      = ("frequency", lambda x: f"{x.mean():.2f}"),
            Monetary_moy  = ("monetary",  lambda x: f"R${x.mean():,.0f}"),
            Score_RFM     = ("rfm_moyen", lambda x: f"{x.mean():.2f}"),
        ).reset_index()

        table = ax7.table(
            cellText=stats_tab.values,
            colLabels=stats_tab.columns,
            cellLoc="center",
            loc="center",
            bbox=[0, 0, 1, 1],
        )
        table.auto_set_font_size(False)
        table.set_fontsize(9)
        # Colorier l'en-tête
        for j in range(len(stats_tab.columns)):
            table[(0, j)].set_facecolor("#2C3E50")
            table[(0, j)].set_text_props(color="white", fontweight="bold")
        ax7.set_title("Tableau récapitulatif des segments RFM", fontweight="bold", pad=10)

        plt.savefig(FIGURES_DIR / "rfm_dashboard.png", dpi=150, bbox_inches="tight")
        plt.show()
        log("Dashboard RFM sauvegardé.")


# ─────────────────────────────────────────────────────────────
# SECTION B : SCORE VENDEURS
# ─────────────────────────────────────────────────────────────

class ScoreVendeurs:
    """
    Calcule un score de performance composite pour chaque vendeur Olist.

    Le score agrège 5 KPIs avec des pondérations métier pour créer
    un classement actionnable (tiers Platine / Or / Argent / Bronze).
    """

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.kpis = None
        self.scores = None

    @timeit
    def calculer_kpis(self) -> pd.DataFrame:
        """
        Calcule les 5 KPIs par vendeur.

        Returns:
            DataFrame avec les KPIs bruts par seller_id
        """
        log("Calcul des KPIs vendeurs...")

        df = self.df.copy()

        # Convertir les dates
        for col in ["order_delivered_customer_date", "order_estimated_delivery_date",
                    "order_purchase_timestamp"]:
            if col in df.columns and df[col].dtype == "object":
                df[col] = pd.to_datetime(df[col])

        # ── KPI 1 : Chiffre d'Affaires total ─────────────────
        # price : prix du produit vendu par ce vendeur
        # ATTENTION : freight_value va au vendeur, pas à Olist
        kpi_ca = (df.groupby("seller_id")["payment_value"]
                    .sum()
                    .rename("ca_total"))

        # ── KPI 2 : Nombre de commandes ────────────────────────
        kpi_volume = (df.groupby("seller_id")["order_id"]
                        .nunique()
                        .rename("nb_commandes"))

        # ── KPI 3 : Note de satisfaction client ────────────────
        # review_score varie de 1 à 5
        kpi_satisfaction = (df.groupby("seller_id")["review_score"]
                              .mean()
                              .rename("note_moyenne"))

        # ── KPI 4 : Taux de livraison dans les délais ─────────
        # Une livraison est "dans les délais" si elle est arrivée
        # avant ou à la date estimée (est_en_retard == False)
        if "est_en_retard" in df.columns:
            kpi_ponctualite = (df.groupby("seller_id")["est_en_retard"]
                                 .apply(lambda x: (~x).mean() * 100)  # % non-retard
                                 .rename("taux_ponctualite"))
        else:
            # Calculer manuellement
            df_livrees = df.dropna(subset=["order_delivered_customer_date",
                                            "order_estimated_delivery_date"])
            df_livrees = df_livrees.copy()
            df_livrees["est_retard"] = (
                df_livrees["order_delivered_customer_date"] >
                df_livrees["order_estimated_delivery_date"]
            )
            kpi_ponctualite = (df_livrees.groupby("seller_id")["est_retard"]
                                          .apply(lambda x: (~x).mean() * 100)
                                          .rename("taux_ponctualite"))

        # ── KPI 5 : Ancienneté (mois depuis première vente) ───
        kpi_anciennete = (df.groupby("seller_id")["order_purchase_timestamp"]
                            .min()
                            .apply(lambda d: (df["order_purchase_timestamp"].max() - d).days / 30)
                            .rename("anciennete_mois"))

        # ── Fusion de tous les KPIs ────────────────────────────
        self.kpis = (pd.concat([kpi_ca, kpi_volume, kpi_satisfaction,
                                  kpi_ponctualite, kpi_anciennete], axis=1)
                       .dropna()       # Vendeurs avec données complètes seulement
                       .reset_index())

        log(f"KPIs calculés pour {len(self.kpis):,} vendeurs")
        log(f"CA médian : R${self.kpis['ca_total'].median():,.0f}")
        log(f"Note moy  : {self.kpis['note_moyenne'].mean():.2f}/5")
        log(f"Ponctualité moy : {self.kpis['taux_ponctualite'].mean():.1f}%")

        return self.kpis

    def scorer_et_classer(self) -> pd.DataFrame:
        """
        Normalise les KPIs et calcule le score composite global.

        Étapes :
        1. Normalisation min-max de chaque KPI -> [0, 1]
        2. Application des pondérations métier
        3. Calcul du score global pondéré
        4. Attribution du tier (Platine / Or / Argent / Bronze)
        """
        if self.kpis is None:
            raise ValueError("Appeler d'abord calculer_kpis()")

        scores = self.kpis.copy()

        # ── Normalisation min-max ─────────────────────────────
        scaler = MinMaxScaler()  # Ramène chaque colonne dans [0, 1]

        colonnes_kpi = ["ca_total", "nb_commandes", "note_moyenne",
                         "taux_ponctualite", "anciennete_mois"]

        scores_normalises = pd.DataFrame(
            scaler.fit_transform(scores[colonnes_kpi]),
            columns=[f"score_{c}" for c in colonnes_kpi],
            index=scores.index,
        )
        # Renommage pour correspondre aux clés de POIDS_VENDEUR
        scores_normalises = scores_normalises.rename(columns={
            "score_ca_total":         "score_ca",
            "score_note_moyenne":     "score_satisfaction",
            "score_taux_ponctualite": "score_ponctualite",
            "score_nb_commandes":     "score_volume",
            "score_anciennete_mois":  "score_anciennete",
        })

        scores = pd.concat([scores, scores_normalises], axis=1)

        # ── Score global pondéré ──────────────────────────────
        scores["score_global"] = sum(
            scores[col] * poids
            for col, poids in POIDS_VENDEUR.items()
        )

        # ── Attribution du tier ────────────────────────────────
        # pd.qcut : quartiles (25% par tier)
        scores["tier"] = pd.qcut(
            scores["score_global"],
            q=4,
            labels=["Bronze", "Argent", "Or", "Platine"],
        )

        # ── Rang global ───────────────────────────────────────
        scores["rang"] = scores["score_global"].rank(ascending=False, method="min").astype(int)

        scores = scores.sort_values("rang")

        self.scores = scores

        # Afficher le top 10 et bottom 10
        log("\nTOP 10 VENDEURS :")
        log(scores.head(10)[["seller_id", "tier", "rang", "score_global",
                               "ca_total", "note_moyenne", "taux_ponctualite"]].to_string())
        log("\nBOTTOM 10 VENDEURS :")
        log(scores.tail(10)[["seller_id", "tier", "rang", "score_global",
                               "ca_total", "note_moyenne", "taux_ponctualite"]].to_string())

        return scores

    def visualiser(self) -> None:
        """Crée un dashboard de performance vendeurs."""
        if self.scores is None:
            raise ValueError("Appeler d'abord calculer_kpis() -> scorer_et_classer()")

        s = self.scores
        fig, axes = plt.subplots(2, 2, figsize=(16, 12))
        fig.suptitle("Performance Vendeurs — Olist", fontsize=15, fontweight="bold")

        # 1. Distribution des tiers
        tier_counts = s["tier"].value_counts()
        colors_tier = ["#CD7F32", "#C0C0C0", "#FFD700", "#E5E4E2"]
        axes[0][0].pie(tier_counts.values, labels=tier_counts.index,
                        colors=colors_tier, autopct="%1.0f%%",
                        startangle=90, pctdistance=0.8,
                        wedgeprops={"linewidth": 2, "edgecolor": "white"})
        axes[0][0].set_title("Répartition des tiers de vendeurs", fontweight="bold")

        # 2. Score global par tier (violin)
        tier_order = ["Platine", "Or", "Argent", "Bronze"]
        sns.violinplot(data=s, x="tier", y="score_global",
                       order=tier_order,
                       palette={"Platine": "#E5E4E2", "Or": "#FFD700",
                                 "Argent": "#C0C0C0", "Bronze": "#CD7F32"},
                       inner="box", ax=axes[0][1])
        axes[0][1].set_title("Distribution du score par tier", fontweight="bold")
        axes[0][1].set_xlabel("Tier")
        axes[0][1].set_ylabel("Score global")

        # 3. Note satisfaction vs CA (coloré par tier)
        palette_tier = {"Platine": "#9B59B6", "Or": "#F1C40F",
                         "Argent": "#7F8C8D", "Bronze": "#E67E22"}
        for tier_val in tier_order:
            g = s[s["tier"] == tier_val]
            axes[1][0].scatter(g["note_moyenne"], g["ca_total"],
                                label=tier_val, alpha=0.5, s=20,
                                color=palette_tier[tier_val])
        axes[1][0].set_xlabel("Note moyenne (/5)")
        axes[1][0].set_ylabel("CA total (R$)")
        axes[1][0].set_title("Satisfaction vs CA par tier", fontweight="bold")
        axes[1][0].set_yscale("log")
        axes[1][0].legend(fontsize=8)

        # 4. Radar chart — profil moyen par tier
        categories = ["CA", "Volume", "Satisfaction", "Ponctualité", "Ancienneté"]
        n_cat = len(categories)
        angles = [n / float(n_cat) * 2 * np.pi for n in range(n_cat)]
        angles += angles[:1]

        ax_radar = plt.subplot(2, 2, 4, polar=True)
        for tier_val, color in palette_tier.items():
            vals = s[s["tier"] == tier_val][
                ["score_ca", "score_volume", "score_satisfaction",
                  "score_ponctualite", "score_anciennete"]
            ].mean().tolist()
            vals += vals[:1]
            ax_radar.plot(angles, vals, "o-", linewidth=2, label=tier_val, color=color)
            ax_radar.fill(angles, vals, alpha=0.1, color=color)

        ax_radar.set_xticks(angles[:-1])
        ax_radar.set_xticklabels(categories, size=9)
        ax_radar.set_ylim(0, 1)
        ax_radar.set_title("Profil moyen par tier", fontweight="bold", pad=20)
        ax_radar.legend(loc="upper right", bbox_to_anchor=(1.3, 1.1), fontsize=8)

        plt.tight_layout()
        plt.savefig(FIGURES_DIR / "vendeurs_performance.png", dpi=150, bbox_inches="tight")
        plt.show()
        log("Dashboard vendeurs sauvegardé.")


# ─────────────────────────────────────────────────────────────
# SECTION C : DÉTECTION D'ANOMALIES
# ─────────────────────────────────────────────────────────────

class DetectionAnomalies:
    """
    Détecte les commandes et paiements anormaux dans le dataset Olist.

    Trois méthodes complémentaires :
    1. Z-score : outliers univariés sur le prix
    2. IQR (Tukey) : outliers sur les frais de livraison
    3. Isolation Forest : anomalies multivariées sur les paiements
    4. Règles métier : incohérences logiques dans les dates
    """

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.anomalies = {}   # Dictionnaire des anomalies détectées

    @timeit
    def detecter_prix_aberrants_zscore(self, seuil: float = 3.0) -> pd.DataFrame:
        """
        Détecte les prix aberrants par la méthode du Z-score.

        Un prix est aberrant si son Z-score dépasse le seuil.
        Z = (x - μ) / σ

        Args:
            seuil: Nombre d'écarts-types au-delà duquel on considère l'anomalie

        Returns:
            DataFrame des commandes avec prix aberrant
        """
        df = self.df.copy()

        # Z-score sur le prix unitaire
        # stats.zscore : calcule le z-score de chaque élément
        df["z_prix"] = np.abs(stats.zscore(df["price"].dropna()))

        # Masque booléen : True = anomalie
        masque_anomalie = df["z_prix"] > seuil

        anomalies = df[masque_anomalie].copy()
        anomalies["methode"] = "Z-score"
        anomalies["detail"] = anomalies["z_prix"].apply(
            lambda z: f"Prix aberrant (Z={z:.2f})"
        )

        log(f"Z-score (seuil={seuil}) : {len(anomalies)} anomalies de prix détectées")
        log(f"  Prix min anomalie : R${anomalies['price'].min():.2f}")
        log(f"  Prix max anomalie : R${anomalies['price'].max():.2f}")

        self.anomalies["z_score"] = anomalies
        return anomalies

    def detecter_livraison_aberrante_iqr(self) -> pd.DataFrame:
        """
        Détecte les frais de livraison aberrants par la méthode IQR (Tukey).

        Un fret est aberrant si :
        - fret > Q3 + 1.5 * IQR (fret anormalement cher)
        - fret < Q1 - 1.5 * IQR (fret anormalement bon marché, souvent 0)

        Returns:
            DataFrame des commandes avec livraison aberrante
        """
        df = self.df.dropna(subset=["freight_value"]).copy()

        Q1  = df["freight_value"].quantile(0.25)
        Q3  = df["freight_value"].quantile(0.75)
        IQR = Q3 - Q1

        borne_inf = Q1 - 1.5 * IQR
        borne_sup = Q3 + 1.5 * IQR

        masque = (df["freight_value"] < borne_inf) | (df["freight_value"] > borne_sup)
        anomalies = df[masque].copy()
        anomalies["methode"] = "IQR"
        anomalies["detail"] = anomalies["freight_value"].apply(
            lambda f: (f"Fret élevé (R${f:.0f} > Q3+1.5×IQR={borne_sup:.0f})"
                       if f > borne_sup else
                       f"Fret nul/négatif (R${f:.2f})")
        )

        log(f"IQR Livraison : {len(anomalies)} anomalies détectées")
        log(f"  Borne sup : R${borne_sup:.2f} | max observé : R${df['freight_value'].max():.2f}")

        self.anomalies["iqr"] = anomalies
        return anomalies

    def detecter_anomalies_paiements_isoforest(
        self, contamination: float = 0.02
    ) -> pd.DataFrame:
        """
        Détecte les paiements anormaux avec Isolation Forest.

        Isolation Forest isole les anomalies en construisant des arbres
        qui partitionnent aléatoirement l'espace des features.
        Les anomalies sont isolées en moins de coupes que les points normaux.

        Args:
            contamination: Proportion attendue d'anomalies (2%)

        Returns:
            DataFrame des paiements considérés comme anomalies
        """
        df = self.df.copy()

        # Variables pour Isolation Forest
        features_iso = ["payment_value", "payment_installments"]

        # Encoder le type de paiement numériquement
        if "payment_type" in df.columns:
            type_map = {"credit_card": 3, "boleto": 2, "voucher": 1,
                         "debit_card": 2, "not_defined": 0}
            df["payment_type_num"] = df["payment_type"].map(type_map).fillna(0)
            features_iso.append("payment_type_num")

        # Supprimer les NaN des features
        df_iso = df[features_iso].dropna()

        # Créer et entraîner le modèle
        iso_forest = IsolationForest(
            n_estimators=100,     # Nombre d'arbres dans la forêt
            contamination=contamination,  # % attendu d'anomalies
            random_state=42,      # Reproductibilité
            n_jobs=-1,            # Utiliser tous les cores
        )

        # fit_predict : +1=normal, -1=anomalie
        predictions = iso_forest.fit_predict(df_iso)
        scores_anomalie = iso_forest.score_samples(df_iso)

        df_iso = df_iso.copy()
        df_iso["prediction"] = predictions
        df_iso["anomaly_score"] = scores_anomalie
        df_iso["methode"] = "Isolation Forest"

        anomalies_iso = df_iso[df_iso["prediction"] == -1]

        log(f"Isolation Forest : {len(anomalies_iso)} anomalies de paiement "
            f"({len(anomalies_iso)/len(df_iso)*100:.1f}%)")
        log(f"  Score min (plus anormal) : {anomalies_iso['anomaly_score'].min():.4f}")

        self.anomalies["isolation_forest"] = anomalies_iso
        return anomalies_iso

    def appliquer_regles_metier(self) -> pd.DataFrame:
        """
        Détecte les incohérences logiques via des règles métier.

        Règles :
        1. Livraison bien avant la date estimée (>10 jours d'avance) : suspect
        2. Commande livrée avant d'avoir été approuvée
        3. Note de review avant la livraison
        4. Prix négatif ou nul
        """
        df = self.df.copy()
        anomalies_list = []

        # Convertir les dates
        for col in ["order_purchase_timestamp", "order_approved_at",
                    "order_delivered_customer_date",
                    "order_estimated_delivery_date",
                    "review_creation_date"]:
            if col in df.columns and df[col].dtype == "object":
                df[col] = pd.to_datetime(df[col])

        # ── Règle 1 : Livraison trop tôt ─────────────────────
        if all(c in df.columns for c in ["order_delivered_customer_date",
                                           "order_estimated_delivery_date"]):
            masque_r1 = (
                df["order_delivered_customer_date"].notna() &
                df["order_estimated_delivery_date"].notna() &
                (
                    (df["order_estimated_delivery_date"] -
                     df["order_delivered_customer_date"]).dt.days > 10
                )
            )
            r1 = df[masque_r1].copy()
            r1["methode"] = "Règle Métier"
            r1["detail"] = "Livraison > 10 jours avant l'estimation"
            anomalies_list.append(r1)
            log(f"Règle 1 (livraison trop tôt) : {len(r1)} anomalies")

        # ── Règle 2 : Livraison avant approbation ─────────────
        if all(c in df.columns for c in ["order_approved_at",
                                           "order_delivered_customer_date"]):
            masque_r2 = (
                df["order_delivered_customer_date"].notna() &
                df["order_approved_at"].notna() &
                (df["order_delivered_customer_date"] < df["order_approved_at"])
            )
            r2 = df[masque_r2].copy()
            r2["methode"] = "Règle Métier"
            r2["detail"] = "Livraison avant approbation"
            anomalies_list.append(r2)
            log(f"Règle 2 (livraison avant approbation) : {len(r2)} anomalies")

        # ── Règle 3 : Prix nul ou négatif ─────────────────────
        if "price" in df.columns:
            masque_r3 = df["price"] <= 0
            r3 = df[masque_r3].copy()
            r3["methode"] = "Règle Métier"
            r3["detail"] = "Prix nul ou négatif"
            anomalies_list.append(r3)
            log(f"Règle 3 (prix ≤ 0) : {len(r3)} anomalies")

        if anomalies_list:
            anomalies_rm = pd.concat(anomalies_list, ignore_index=True)
        else:
            anomalies_rm = pd.DataFrame()

        self.anomalies["regles_metier"] = anomalies_rm
        return anomalies_rm

    def rapport_anomalies(self) -> pd.DataFrame:
        """Synthèse de toutes les anomalies détectées."""
        synthese = {
            methode: len(df_an)
            for methode, df_an in self.anomalies.items()
            if isinstance(df_an, pd.DataFrame) and not df_an.empty
        }

        log("\n══ RAPPORT DES ANOMALIES ══")
        total_commandes = len(self.df)
        for methode, count in synthese.items():
            pct = count / total_commandes * 100
            log(f"  {methode:25s} : {count:5d} anomalies ({pct:.2f}%)")

        # Visualisation
        fig, axes = plt.subplots(1, 2, figsize=(14, 5))
        fig.suptitle("Détection d'Anomalies — Olist", fontsize=14, fontweight="bold")

        # Barplot du nb d'anomalies par méthode
        methodes = list(synthese.keys())
        counts   = list(synthese.values())
        colors = ["#E74C3C", "#E67E22", "#9B59B6", "#3498DB"][:len(methodes)]
        bars = axes[0].bar(methodes, counts, color=colors, edgecolor="white")
        for bar, cnt in zip(bars, counts):
            axes[0].text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1,
                         f"{cnt:,}", ha="center", va="bottom", fontweight="bold")
        axes[0].set_title("Nombre d'anomalies par méthode")
        axes[0].set_ylabel("Nb anomalies")
        axes[0].tick_params(axis="x", rotation=15)

        # Distribution des prix des anomalies vs normal
        if "z_score" in self.anomalies and not self.anomalies["z_score"].empty:
            prix_normaux    = self.df[~self.df.index.isin(
                self.anomalies["z_score"].index)]["price"].dropna()
            prix_anomalies  = self.anomalies["z_score"]["price"].dropna()

            axes[1].hist(np.log1p(prix_normaux), bins=50, alpha=0.6,
                          label="Normal", color="steelblue", density=True)
            axes[1].hist(np.log1p(prix_anomalies), bins=20, alpha=0.7,
                          label="Anomalie Z-score", color="red", density=True)
            axes[1].set_xlabel("log(Prix + 1)")
            axes[1].set_ylabel("Densité")
            axes[1].set_title("Distribution Prix : Normal vs Anomalies")
            axes[1].legend()

        plt.tight_layout()
        plt.savefig(FIGURES_DIR / "anomalies_rapport.png", dpi=150, bbox_inches="tight")
        plt.show()

        return pd.DataFrame(
            {"methode": methodes, "nb_anomalies": counts}
        ).set_index("methode")


# ─────────────────────────────────────────────────────────────
# SECTION D : ANALYSE DE COHORTE
# ─────────────────────────────────────────────────────────────

@timeit
def analyse_cohorte(df: pd.DataFrame) -> pd.DataFrame:
    """
    Analyse la rétention des clients par cohorte mensuelle.

    Principe :
    - Cohorte = groupe de clients ayant passé leur 1ère commande le même mois
    - Pour chaque cohorte, on mesure combien de clients passent encore commande
      dans les mois suivants

    Returns:
        DataFrame de rétention : index=mois de 1ère commande, colonnes=mois d'écart
    """
    log("Calcul de l'analyse de cohorte...")

    df_c = df.copy()

    # Convertir la date si nécessaire
    if df_c["order_purchase_timestamp"].dtype == "object":
        df_c["order_purchase_timestamp"] = pd.to_datetime(
            df_c["order_purchase_timestamp"]
        )

    # ── Mois de la commande ────────────────────────────────────
    # to_period("M") : convertit datetime en période mensuelle "2017-01"
    df_c["order_month"] = df_c["order_purchase_timestamp"].dt.to_period("M")

    # ── Mois de la 1ère commande du client ─────────────────────
    # groupby("customer_unique_id")["order_month"].transform("min") :
    # pour chaque ligne, retourne le mois minimum du client
    df_c["cohort_month"] = (df_c.groupby("customer_unique_id")["order_month"]
                                  .transform("min"))

    # ── Écart en mois entre la commande et la cohorte ─────────
    # (order_month - cohort_month).n : nombre de mois d'écart
    # ordinal : représentation numérique d'une période
    df_c["cohort_index"] = (
        df_c["order_month"].apply(lambda x: x.ordinal) -
        df_c["cohort_month"].apply(lambda x: x.ordinal)
    )

    # ── Compter les clients uniques par cohorte et mois ────────
    cohort_data = (df_c.groupby(["cohort_month", "cohort_index"])
                       ["customer_unique_id"]
                       .nunique()
                       .reset_index())

    # ── Pivot : cohortes en lignes, mois d'écart en colonnes ──
    cohort_pivot = cohort_data.pivot(
        index="cohort_month",
        columns="cohort_index",
        values="customer_unique_id"
    )

    # ── Taux de rétention (%) ─────────────────────────────────
    # Diviser chaque ligne par sa valeur initiale (colonne 0 = taille de la cohorte)
    cohort_size = cohort_pivot.iloc[:, 0]   # Taille initiale de chaque cohorte
    retention   = cohort_pivot.divide(cohort_size, axis=0) * 100

    # ── Visualisation ─────────────────────────────────────────
    fig, axes = plt.subplots(1, 2, figsize=(18, 7))
    fig.suptitle("Analyse de Cohorte — Rétention Clients Olist", fontsize=14)

    # Heatmap de rétention (seulement les 12 premiers mois)
    retention_12 = retention.iloc[:, :12]
    sns.heatmap(
        retention_12,
        annot=True,
        fmt=".0f",
        cmap="YlGnBu",
        ax=axes[0],
        linewidths=0.3,
        cbar_kws={"label": "Taux de rétention (%)"},
        mask=retention_12.isna(),
    )
    axes[0].set_title("Taux de rétention par cohorte (%)", fontweight="bold")
    axes[0].set_xlabel("Mois depuis 1ère commande")
    axes[0].set_ylabel("Mois de 1ère commande (Cohorte)")

    # Courbe de rétention agrégée
    retention_moy = retention.mean(axis=0).dropna()
    axes[1].plot(retention_moy.index, retention_moy.values,
                  "o-", linewidth=2, color="steelblue", markersize=6)
    axes[1].fill_between(retention_moy.index, 0, retention_moy.values,
                           alpha=0.15, color="steelblue")
    axes[1].set_xlabel("Mois depuis 1ère commande")
    axes[1].set_ylabel("Taux de rétention moyen (%)")
    axes[1].set_title("Courbe de rétention agrégée", fontweight="bold")
    axes[1].grid(alpha=0.3)

    # Annoter le taux à M+1
    if len(retention_moy) > 1:
        taux_m1 = retention_moy.iloc[1]
        axes[1].annotate(
            f"M+1 : {taux_m1:.1f}%",
            xy=(1, taux_m1),
            xytext=(2, taux_m1 + 5),
            arrowprops=dict(arrowstyle="->"),
            fontsize=10,
        )

    plt.tight_layout()
    plt.savefig(FIGURES_DIR / "cohorte_retention.png", dpi=150, bbox_inches="tight")
    plt.show()

    log(f"Taux de rétention M+1 moyen : {retention_moy.iloc[1]:.1f}%")
    log("Heatmap de cohorte sauvegardée.")

    return retention


# ─────────────────────────────────────────────────────────────
# PIPELINE PRINCIPAL
# ─────────────────────────────────────────────────────────────

def main_analyse_avancee(df: pd.DataFrame) -> dict:
    """
    Exécute toutes les analyses avancées en séquence.

    Args:
        df: DataFrame maître Olist (sortie de OlistDataLoader)

    Returns:
        Dictionnaire avec tous les résultats
    """
    resultats = {}

    log("═══════════════════════════════════════════")
    log("  ANALYSE AVANCÉE — PARTIE 5               ")
    log("═══════════════════════════════════════════")

    # ── Section A : RFM ───────────────────────────────────────
    log("\n[A] ANALYSE RFM")
    rfm_analyser = AnalyseRFM(df)
    rfm_analyser.calculer_rfm()
    rfm_analyser.scorer_rfm()
    rfm_analyser.segmenter()
    rfm_analyser.visualiser()
    resultats["rfm"] = rfm_analyser.rfm

    # ── Section B : Vendeurs ──────────────────────────────────
    log("\n[B] SCORE VENDEURS")
    score_vendeurs = ScoreVendeurs(df)
    score_vendeurs.calculer_kpis()
    score_vendeurs.scorer_et_classer()
    score_vendeurs.visualiser()
    resultats["vendeurs"] = score_vendeurs.scores

    # ── Section C : Anomalies ─────────────────────────────────
    log("\n[C] DÉTECTION D'ANOMALIES")
    detecteur = DetectionAnomalies(df)
    detecteur.detecter_prix_aberrants_zscore(seuil=3.0)
    detecteur.detecter_livraison_aberrante_iqr()
    detecteur.detecter_anomalies_paiements_isoforest(contamination=0.02)
    detecteur.appliquer_regles_metier()
    rapport_an = detecteur.rapport_anomalies()
    resultats["anomalies"] = detecteur.anomalies

    # ── Section D : Cohortes ──────────────────────────────────
    log("\n[D] ANALYSE DE COHORTE")
    retention = analyse_cohorte(df)
    resultats["retention"] = retention

    log("\n[OK] Analyse avancée terminée.")
    return resultats
```

---

## 6⃣ Explication Ligne par Ligne

### Bloc RFM — `calculer_rfm()`

```python
df_livrees = self.df[self.df["order_status"] == "delivered"].copy()
```
- `self.df["order_status"] == "delivered"` -> crée un masque booléen True/False
- `self.df[masque]` -> filtre le DataFrame pour ne garder que les lignes True
- `.copy()` -> **toujours** copier pour éviter de modifier l'original (SettingWithCopyWarning)

```python
date_ref = df_livrees["order_purchase_timestamp"].max() + pd.Timedelta(days=1)
```
- `.max()` -> trouve la date la plus récente dans la colonne
- `pd.Timedelta(days=1)` -> ajoute exactement 1 jour
- Résultat : le client qui a commandé le plus récemment aura `recency = 1 jour`

```python
rfm = df_livrees.groupby("customer_unique_id").agg(
    recency   = ("order_purchase_timestamp", lambda x: (date_ref - x.max()).days),
    frequency = ("order_id", "nunique"),
    monetary  = ("montant_total", "sum"),
).reset_index()
```
- `groupby("customer_unique_id")` -> regroupe toutes les commandes du même client
- `.agg(nom_col = ("source_col", fonction))` -> syntaxe **named aggregation** (Pandas ≥ 0.25)
- `lambda x: (date_ref - x.max()).days` -> pour chaque groupe, calcule le nb de jours
  - `x.max()` : dernière date de commande du client
  - `date_ref - x.max()` : `Timedelta` (durée)
  - `.days` : extrait le nombre de jours entiers
- `"nunique"` : compte les valeurs distinctes (order_id)
- `.reset_index()` : transforme le `customer_unique_id` (qui est l'index du groupby) en colonne normale

### Bloc RFM — `scorer_rfm()`

```python
rfm["score_r"] = pd.qcut(
    rfm["recency"],
    q=5,
    labels=[5, 4, 3, 2, 1],
    duplicates="drop"
).astype(int)
```
- `pd.qcut(serie, q=5)` -> divise en 5 quintiles de même effectif
- `labels=[5,4,3,2,1]` -> **inversé** car recency élevé = vieux client = mauvais score
  - Quintile 1 (0-20% plus faible recency) -> label 5 (très récent = bien)
  - Quintile 5 (80-100% plus haute recency) -> label 1 (très ancien = mal)
- `duplicates="drop"` -> gère les valeurs identiques aux frontières de quintiles
- `.astype(int)` -> convertit les labels categoricals en entiers

### Bloc Isolation Forest

```python
iso_forest = IsolationForest(
    n_estimators=100,
    contamination=contamination,
    random_state=42,
    n_jobs=-1,
)
predictions = iso_forest.fit_predict(df_iso)
```
- `n_estimators=100` : 100 arbres d'isolation -> plus c'est grand, plus c'est robuste
- `contamination=0.02` : on dit au modèle qu'on s'attend à 2% d'anomalies
  - Ce paramètre fixe le seuil de décision interne
- `random_state=42` : graine aléatoire -> résultats reproductibles
- `n_jobs=-1` : utilise tous les cœurs CPU disponibles (parallélisation)
- `fit_predict(X)` : entraîne le modèle ET fait les prédictions en une seule étape
  - Retourne `+1` pour les points normaux
  - Retourne `-1` pour les anomalies

---

## 7⃣ Analyse et Interprétation — Insights Réels Olist

**Segments RFM Olist :**
- Plus de **90% des clients** ont une seule commande (Frequency=1)
  -> Olist est un marché de première intention, pas un marché de fidélisation
- Les **Champions** représentent généralement 3-5% des clients mais 20-30% du CA
- La **Recency médiane** est ~300 jours -> nombreux clients inactifs

**Performance Vendeurs :**
- Le top 5% des vendeurs génèrent ~40% du CA total (loi de Pareto)
- Les vendeurs Platine ont systématiquement une note > 4.2/5
- Les vendeurs Bronze montrent souvent une corrélation retard <-> mauvaise note

**Anomalies :**
- Les anomalies de prix (Z-score) incluent souvent des ensembles (canapé + livraison)
- Les frais de livraison anormaux concernent surtout le Nordeste brésilien (distances extrêmes)
- Isolation Forest identifie des paiements en 24 versements avec montant > R$10,000 comme suspects

---

## 8⃣ Bonnes Pratiques

```python
# [OK] BON : travailler sur une copie pour ne pas altérer l'original
df_c = df.copy()

# [OK] BON : vérifier les invariants après chaque transformation
assert (rfm["frequency"] >= 1).all(), "Frequency doit être ≥ 1"

# [OK] BON : logger chaque étape pour faciliter le débogage
log(f"RFM calculé : {len(rfm):,} clients uniques")

# [OK] BON : named aggregation -> code lisible, pas de renommage post-hoc
rfm = df.groupby("id").agg(
    total  = ("montant", "sum"),
    nb     = ("commande_id", "nunique"),
)

# [X] MAUVAIS : modifier le df original dans une méthode de classe
self.df["score"] = ...  # Modifie l'original -> effets de bord

# [OK] BON : travailler sur une copie locale
df = self.df.copy()
df["score"] = ...
```

---

## 9⃣ Erreurs Fréquentes

**Erreur 1 : Inversion manquante pour la Recency**
```python
# [X] MAUVAIS : score_r = 1 pour le client le plus récent
rfm["score_r"] = pd.qcut(rfm["recency"], q=5, labels=[1,2,3,4,5])

# [OK] BON : score_r = 5 pour le client le plus récent
rfm["score_r"] = pd.qcut(rfm["recency"], q=5, labels=[5,4,3,2,1])
```

**Erreur 2 : Data leakage dans le scaler**
```python
# [X] MAUVAIS : fit sur tout le dataset (y compris les nouveaux clients)
scaler.fit(tous_les_vendeurs[colonnes])

# [OK] BON : fit sur les vendeurs "historiques", transform sur les nouveaux
scaler.fit(vendeurs_historiques[colonnes])
scores_nouveaux = scaler.transform(nouveaux_vendeurs[colonnes])
```

**Erreur 3 : Contamination trop élevée pour Isolation Forest**
```python
# [X] MAUVAIS : contamination=0.5 -> moitié du dataset marqué anomalie !
IsolationForest(contamination=0.5)

# [OK] BON : contamination=0.01 à 0.05 selon le contexte métier
IsolationForest(contamination=0.02)
```

---

## [10] Exercices

### [VERT] Facile

**Ex 5.1 :** Modifiez `scorer_rfm()` pour utiliser 4 quartiles au lieu de 5 quintiles. Comparez la distribution des segments obtenus.

**Ex 5.2 :** Ajoutez un 6ème KPI vendeur : `taux_avis` = (nb_reviews / nb_commandes × 100). Quelle pondération lui donneriez-vous ?

**Ex 5.3 :** Dans `detecter_prix_aberrants_zscore()`, testez avec seuil=2.5 vs seuil=3.0 vs seuil=3.5. Comment évolue le nombre d'anomalies ?

### [JAUNE] Intermédiaire

**Ex 5.4 :** Créez une fonction `valeur_par_segment(rfm_df)` qui calcule le **CLV estimé** (Customer Lifetime Value = monetary × frequency × (1/recency_normalisée)) pour chaque segment.

**Ex 5.5 :** Ajoutez une méthode `comparer_periodes(df, periode_a, periode_b)` dans `AnalyseRFM` qui compare la distribution RFM entre deux semestres (ex: H1 2017 vs H2 2017).

**Ex 5.6 :** Étendez `DetectionAnomalies` pour détecter les **doublons de commande** : mêmes (customer_id, seller_id, price) à moins de 24h d'intervalle.

### [ROUGE] Avancé

**Ex 5.7 :** Remplacez l'attribution de segments par règles (`np.select`) par un **clustering K-Means** sur les scores RFM normalisés. Utilisez la méthode du coude pour choisir k. Comparez les clusters obtenus avec les segments manuels.

**Ex 5.8 :** Implémentez une **analyse de séquence d'achat** : pour les clients avec frequency ≥ 2, calculez le délai moyen inter-commandes et identifiez les produits achetés en séquence. Utilisez `shift()` et `diff()` sur les données triées par date.

---

## 1⃣1⃣ Corrigé Ultra Détaillé

### Corrigé Ex 5.7 — K-Means sur RFM

```python
# ============================================================
# Corrigé Exercice 5.7 : K-Means sur scores RFM
# ============================================================

from sklearn.cluster import KMeans
from sklearn.preprocessing import MinMaxScaler
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

def segmenter_kmeans(rfm: pd.DataFrame, k_max: int = 10) -> pd.DataFrame:
    """
    Segmente les clients par K-Means sur les scores RFM normalisés.
    Utilise la méthode du coude pour choisir k automatiquement.

    Args:
        rfm    : DataFrame avec colonnes recency, frequency, monetary
        k_max  : Nombre maximum de clusters à tester

    Returns:
        rfm avec colonne 'cluster_kmeans' ajoutée
    """
    # ── Étape 1 : Normaliser les métriques brutes ──────────────
    scaler = MinMaxScaler()
    features = ["recency", "frequency", "monetary"]

    # Inverser la recency : recency faible = bon -> normaliser en inversant
    rfm_norm = rfm[features].copy()
    rfm_norm["recency"] = rfm_norm["recency"].max() - rfm_norm["recency"]
    # Maintenant : recency_inversée élevée = client récent

    X = scaler.fit_transform(rfm_norm)

    # ── Étape 2 : Méthode du coude (Elbow Method) ─────────────
    # On calcule l'inertie pour k=2 à k_max
    # Inertie = somme des distances² de chaque point à son centroïde
    # Plus k augmente, plus l'inertie diminue (évident)
    # Le "coude" est là où le gain marginal devient faible
    inerties = []
    k_range = range(2, k_max + 1)

    for k in k_range:
        km = KMeans(n_clusters=k, random_state=42, n_init=10)
        km.fit(X)
        inerties.append(km.inertia_)

    # Visualiser le coude
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    axes[0].plot(k_range, inerties, "o-", linewidth=2, color="steelblue")
    axes[0].set_xlabel("Nombre de clusters k")
    axes[0].set_ylabel("Inertie")
    axes[0].set_title("Méthode du coude — Choix de k")
    axes[0].grid(alpha=0.3)

    # Dérivée seconde pour trouver le coude automatiquement
    derivee_2 = np.diff(inerties, n=2)
    k_optimal = list(k_range)[np.argmax(derivee_2) + 1]
    axes[0].axvline(k_optimal, color="red", linestyle="--",
                     label=f"k optimal = {k_optimal}")
    axes[0].legend()

    # ── Étape 3 : Entraîner le modèle final ───────────────────
    km_final = KMeans(
        n_clusters=k_optimal,
        random_state=42,
        n_init=10,          # 10 initialisations aléatoires, garder la meilleure
        max_iter=300        # Nombre max d'itérations
    )
    rfm["cluster_kmeans"] = km_final.fit_predict(X)

    # ── Étape 4 : Profil des clusters ─────────────────────────
    profil = rfm.groupby("cluster_kmeans").agg(
        nb_clients    = ("customer_unique_id", "count"),
        recency_moy   = ("recency", "mean"),
        frequency_moy = ("frequency", "mean"),
        monetary_moy  = ("monetary", "mean"),
    ).round(2)

    # Nommer les clusters automatiquement selon le profil
    # Le cluster avec la recency la plus basse ET monetary le plus haut = Champion
    profil["score_qualite"] = (
        -profil["recency_moy"] +      # Moins bon si vieux
         profil["frequency_moy"] * 50 +  # Plus bon si fréquent
         profil["monetary_moy"] / 100    # Plus bon si gros dépensier
    )

    noms_clusters = {
        idx: nom for idx, nom in
        zip(profil["score_qualite"].sort_values(ascending=False).index,
            ["Champion", "Fidèle", "Potentiel", "À Risque", "Perdu"][:k_optimal])
    }
    rfm["segment_kmeans"] = rfm["cluster_kmeans"].map(noms_clusters)

    print(f"\nk optimal trouvé : {k_optimal}")
    print("\nProfil des clusters :")
    print(profil.to_string())

    # ── Étape 5 : Scatter 3D des clusters ─────────────────────
    from mpl_toolkits.mplot3d import Axes3D
    ax3d = fig.add_subplot(122, projection='3d')  # Ne fonctionne pas avec add_subplot existant

    colors_k = plt.cm.tab10(np.linspace(0, 1, k_optimal))
    for cluster_id in range(k_optimal):
        mask = rfm["cluster_kmeans"] == cluster_id
        ax3d = axes[1]
        # Scatter 2D (frequency vs monetary, coloré par recency)
        sc = axes[1].scatter(
            rfm[mask]["frequency"],
            rfm[mask]["monetary"],
            c=rfm[mask]["recency"],
            cmap="RdYlGn_r",
            alpha=0.4,
            s=15,
            label=f"Cluster {cluster_id}: {noms_clusters.get(cluster_id,'?')}"
        )

    plt.colorbar(sc, ax=axes[1], label="Recency (jours)")
    axes[1].set_xlabel("Frequency")
    axes[1].set_ylabel("Monetary (R$)")
    axes[1].set_yscale("log")
    axes[1].set_title(f"Clusters K-Means (k={k_optimal})\nColoré par Recency")
    axes[1].legend(fontsize=7)

    plt.tight_layout()
    plt.savefig("rfm_kmeans.png", dpi=150, bbox_inches="tight")
    plt.show()

    return rfm

# ── Utilisation ───────────────────────────────────────────────
# rfm_avec_clusters = segmenter_kmeans(rfm_df, k_max=8)
# print(rfm_avec_clusters["segment_kmeans"].value_counts())
```

---

## [LISTE] Tableau Récapitulatif — Partie 5

| Concept | Méthode | Fichier |
|---|---|---|
| Analyse RFM | `qcut`, `groupby`, `np.select` | `src/advanced_analysis.py` |
| Score Vendeur | `MinMaxScaler`, pondération | `AnalyseRFM.scorer_rfm()` |
| Anomalies Z-score | `scipy.stats.zscore` | `DetectionAnomalies` |
| Anomalies IQR | Q1, Q3, IQR, borne_sup/inf | `detecter_livraison_iqr()` |
| Isolation Forest | `IsolationForest.fit_predict()` | `detecter_anomalies_isoforest()` |
| Analyse cohorte | `to_period("M")`, `pivot`, heatmap | `analyse_cohorte()` |
| K-Means RFM | `KMeans`, méthode du coude | Exercice 5.7 |

---

*DataInsight Pro — Partie 5/8 | Dataset : Olist Brazilian E-Commerce*

# [GRAPHIQUE] DataInsight Pro — Partie 6
## Analyse Business Approfondie : LTV, Prévision CA, Analyse Géographique

**Dataset : Olist Brazilian E-Commerce** (https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce)
**Prérequis :** Parties 1-5 complétées — `data/processed/olist_master.parquet`

---

## 1⃣ Contexte Métier Réel

**Problématique :** La direction d'Olist prépare le plan stratégique 2019. Elle a besoin de :
1. **Estimer le LTV** (Lifetime Value) de chaque segment client pour décider du budget d'acquisition
2. **Prévoir le CA mensuel** des 6 prochains mois pour anticiper les ressources
3. **Analyser la performance géographique** pour identifier les états à développer
4. **Comprendre la saisonnalité** pour planifier les campagnes marketing

---

## 2⃣ Objectifs Pédagogiques

| Compétence | Concept |
|---|---|
| LTV client | `groupby`, méthode BG/NBD, `apply` |
| Prévision time series | `resample`, `rolling`, décomposition saisonnière |
| Régression temporelle | `numpy.polyfit`, `scipy.stats.linregress` |
| Analyse géographique | `groupby` multi-niveaux, comparaisons |
| Visualisation cartes | `plotly.express.choropleth` |
| Tests statistiques | ANOVA, tests de tendance |

---

## 3⃣ Énoncé Détaillé

### Tâche A — LTV (Lifetime Value)
- Calculer le LTV observé par client (somme réelle dépensée sur la période)
- Estimer le LTV futur via la formule simplifiée : `LTV = AOV × Freq × durée_vie`
- Segmenter et visualiser le LTV par état brésilien et par catégorie produit

### Tâche B — Prévision CA
- Créer la série temporelle mensuelle du CA
- Identifier tendance, saisonnalité et bruit (décomposition STL)
- Construire un modèle de prévision simple (moyenne mobile pondérée + tendance)
- Évaluer le modèle sur les 3 derniers mois connus (backtesting)

### Tâche C — Analyse Géographique
- Calculer les KPIs par état (CA, nb_commandes, panier moyen, satisfaction)
- Identifier les états sur-performants et sous-performants
- Comparer la distribution livraison Nord vs Sud du Brésil

---

## 4⃣ Théorie Appliquée

### LTV — Customer Lifetime Value

Le **LTV** mesure la valeur totale qu'un client apporte sur toute sa durée de vie.

```
LTV observé  = Σ(achats sur la période)        <- rétrospectif, exact
LTV estimé   = AOV × f × T × mg               <- prospectif, prédictif
```

Avec :
- `AOV` = Average Order Value (panier moyen)
- `f`   = Fréquence d'achat par mois
- `T`   = Durée de vie attendue en mois
- `mg`  = Marge brute (si disponible)

**Formule simplifiée Olist :**
```
LTV_12m = monetary_moy × frequency_moy × (12 / anciennete_mois)
```

### Décomposition Saisonnière (STL)

Une série temporelle se décompose en :
```
Yt = Tt + St + Rt
```
- `Tt` = Tendance (trend) : direction à long terme
- `St` = Saisonnalité (seasonal) : patterns répétitifs (hebdomadaire, mensuel)
- `Rt` = Résidu (remainder) : bruit aléatoire

### Régression Linéaire sur Séries Temporelles

Pour modéliser la tendance :
```python
# Coder le temps en entiers : t = 0, 1, 2, ..., n-1
t = np.arange(len(serie))
slope, intercept, r, p, se = scipy.stats.linregress(t, serie)
# slope = croissance mensuelle du CA
# r²    = part de variance expliquée par la tendance
```

---

## 5⃣ Implémentation Complète

### Fichier : `src/business_analysis.py`

```python
# ============================================================
# src/business_analysis.py
# Analyse Business Approfondie : LTV, Prévision, Géographie
# Projet : DataInsight Pro — Dataset Olist
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from scipy import stats
from scipy.signal import savgol_filter  # Lissage Savitzky-Golay
from pathlib import Path
from typing import Tuple, Dict, Optional
import warnings
warnings.filterwarnings("ignore")

# Imports internes
import sys
sys.path.insert(0, str(Path(__file__).parent))
from utils import ROOT_DIR, DATA_PROCESSED_DIR, REPORTS_DIR, timeit, log

FIGURES_DIR = REPORTS_DIR / "figures"
FIGURES_DIR.mkdir(parents=True, exist_ok=True)


# ─────────────────────────────────────────────────────────────
# SECTION A : LIFETIME VALUE (LTV)
# ─────────────────────────────────────────────────────────────

class AnalyseLTV:
    """
    Calcule et analyse le Lifetime Value des clients Olist.

    Le LTV est LE KPI central en e-commerce : il détermine combien
    on peut dépenser pour acquérir un client (CAC ≤ LTV / 3 en général).
    """

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.ltv_clients = None
        self.ltv_segments = None

    @timeit
    def calculer_ltv_observe(self) -> pd.DataFrame:
        """
        Calcule le LTV observé (réel) pour chaque client unique.

        LTV observé = total des paiements du client sur la période analysée
        C'est la vérité terrain, mais limité aux données disponibles.

        Returns:
            DataFrame avec colonnes client + métriques LTV
        """
        log("Calcul du LTV observé...")

        df = self.df.copy()

        # Filtrer commandes livrées
        df_ok = df[df["order_status"] == "delivered"].copy()

        if "montant_total" not in df_ok.columns:
            df_ok["montant_total"] = df_ok["price"].fillna(0) + df_ok["freight_value"].fillna(0)

        # Convertir timestamp
        if df_ok["order_purchase_timestamp"].dtype == "object":
            df_ok["order_purchase_timestamp"] = pd.to_datetime(
                df_ok["order_purchase_timestamp"]
            )

        # ── Métriques par client ───────────────────────────────
        ltv = df_ok.groupby("customer_unique_id").agg(
            ltv_observe      = ("montant_total",                "sum"),    # Total dépensé
            nb_commandes     = ("order_id",                    "nunique"), # Nb commandes
            aov              = ("montant_total",                "mean"),   # Panier moyen
            premiere_cmd     = ("order_purchase_timestamp",    "min"),
            derniere_cmd     = ("order_purchase_timestamp",    "max"),
            nb_categories    = ("product_category_name",       "nunique"), # Diversité
        ).reset_index()

        # ── Ancienneté en mois ─────────────────────────────────
        date_max = df_ok["order_purchase_timestamp"].max()
        # (date_max - premiere_cmd).dt.days : nb de jours depuis 1ère commande
        # / 30.44 : convertir en mois (30.44 = nombre moyen de jours par mois)
        ltv["anciennete_mois"] = (
            (date_max - ltv["premiere_cmd"]).dt.days / 30.44
        ).clip(lower=0.1)  # Au minimum 0.1 mois (évite division par zéro)

        # ── Fréquence par mois ─────────────────────────────────
        # freq_mensuelle : combien de commandes par mois en moyenne
        ltv["freq_mensuelle"] = ltv["nb_commandes"] / ltv["anciennete_mois"]

        # ── LTV projeté 12 mois ────────────────────────────────
        # Hypothèse : le comportement passé continue
        # LTV_12m = panier_moyen × commandes_mensuelles × 12
        ltv["ltv_projete_12m"] = ltv["aov"] * ltv["freq_mensuelle"] * 12

        # ── Segmentation par LTV ──────────────────────────────
        # pd.qcut avec q=4 : quartiles
        ltv["segment_ltv"] = pd.qcut(
            ltv["ltv_observe"],
            q=4,
            labels=["Faible (Q1)", "Moyen (Q2)", "Bon (Q3)", "Excellent (Q4)"],
            duplicates="drop"
        )

        # Statistiques de synthèse
        log(f"\nLTV calculé pour {len(ltv):,} clients")
        log(f"LTV moyen observé  : R${ltv['ltv_observe'].mean():,.2f}")
        log(f"LTV médian observé : R${ltv['ltv_observe'].median():,.2f}")
        log(f"LTV max observé    : R${ltv['ltv_observe'].max():,.2f}")
        log(f"LTV projeté 12m moy: R${ltv['ltv_projete_12m'].mean():,.2f}")

        self.ltv_clients = ltv
        return ltv

    @timeit
    def calculer_ltv_par_etat(self) -> pd.DataFrame:
        """
        Agrège le LTV moyen par état brésilien.

        Permet d'identifier les marchés à forte valeur client
        pour orienter les investissements marketing régionaux.
        """
        if self.ltv_clients is None:
            raise ValueError("Appeler d'abord calculer_ltv_observe()")

        # Joindre avec les données de localisation
        # Le customer_state vient de df_customers
        if "customer_state" not in self.df.columns:
            log("ATTENTION : customer_state non disponible dans le DataFrame")
            return pd.DataFrame()

        # Créer un mapping client -> état
        mapping_etat = (self.df[["customer_unique_id", "customer_state"]]
                            .drop_duplicates("customer_unique_id")
                            .set_index("customer_unique_id")["customer_state"])

        ltv = self.ltv_clients.copy()
        ltv["etat"] = ltv["customer_unique_id"].map(mapping_etat)

        # Agrégation par état
        ltv_etat = ltv.groupby("etat").agg(
            nb_clients     = ("customer_unique_id", "count"),
            ltv_moy        = ("ltv_observe",        "mean"),
            ltv_median     = ("ltv_observe",        "median"),
            ltv_total      = ("ltv_observe",        "sum"),
            aov_moy        = ("aov",                "mean"),
            freq_moy       = ("freq_mensuelle",     "mean"),
            ltv_12m_moy    = ("ltv_projete_12m",    "mean"),
        ).round(2)

        # Trier par LTV moyen
        ltv_etat = ltv_etat.sort_values("ltv_moy", ascending=False)

        log(f"\nTop 5 états par LTV moyen :")
        log(ltv_etat.head(5)[["nb_clients", "ltv_moy", "aov_moy"]].to_string())

        self.ltv_segments = ltv_etat
        return ltv_etat

    def visualiser(self) -> None:
        """Dashboard LTV complet."""
        if self.ltv_clients is None:
            raise ValueError("Lancer calculer_ltv_observe() d'abord")

        ltv = self.ltv_clients
        fig = plt.figure(figsize=(20, 12))
        fig.suptitle("Analyse LTV (Lifetime Value) — Olist", fontsize=15, fontweight="bold")
        gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.40, wspace=0.35)

        # 1. Distribution du LTV observé (log scale)
        ax1 = fig.add_subplot(gs[0, 0])
        ax1.hist(np.log1p(ltv["ltv_observe"]), bins=50,
                  color="steelblue", edgecolor="white", alpha=0.8)
        ax1.set_xlabel("log(LTV + 1) [R$]")
        ax1.set_ylabel("Nb clients")
        ax1.set_title(f"Distribution LTV observé\n"
                       f"Moy={ltv['ltv_observe'].mean():,.0f} R$ | "
                       f"Med={ltv['ltv_observe'].median():,.0f} R$")

        # 2. LTV par segment
        ax2 = fig.add_subplot(gs[0, 1])
        seg_ltv = ltv.groupby("segment_ltv")["ltv_observe"].agg(["mean", "count"])
        bars = ax2.bar(seg_ltv.index, seg_ltv["mean"],
                        color=["#E74C3C", "#E67E22", "#F1C40F", "#2ECC71"],
                        edgecolor="white")
        for bar, (_, row) in zip(bars, seg_ltv.iterrows()):
            ax2.text(bar.get_x() + bar.get_width() / 2,
                      bar.get_height() + 5,
                      f"R${row['mean']:,.0f}\n(n={row['count']:,})",
                      ha="center", va="bottom", fontsize=7.5)
        ax2.set_title("LTV moyen par segment")
        ax2.set_ylabel("LTV moyen (R$)")
        ax2.tick_params(axis="x", rotation=15)

        # 3. AOV vs Fréquence (scatter)
        ax3 = fig.add_subplot(gs[0, 2])
        sample_ltv = ltv.sample(min(5000, len(ltv)), random_state=42)
        sc = ax3.scatter(
            sample_ltv["freq_mensuelle"],
            sample_ltv["aov"],
            c=sample_ltv["ltv_observe"],
            cmap="YlOrRd",
            alpha=0.4,
            s=10,
        )
        plt.colorbar(sc, ax=ax3, label="LTV (R$)")
        ax3.set_xlabel("Fréquence mensuelle")
        ax3.set_ylabel("AOV - Panier moyen (R$)")
        ax3.set_title("AOV vs Fréquence\n(couleur = LTV)")
        ax3.set_xscale("log")
        ax3.set_yscale("log")

        # 4. LTV par état (top 15)
        if self.ltv_segments is not None and len(self.ltv_segments) > 0:
            ax4 = fig.add_subplot(gs[1, :2])
            top_etats = self.ltv_segments["ltv_moy"].sort_values(ascending=True).tail(15)
            colors_map = plt.cm.RdYlGn(np.linspace(0.3, 1.0, len(top_etats)))
            bars_h = ax4.barh(top_etats.index, top_etats.values,
                               color=colors_map, edgecolor="white")
            for bar, val in zip(bars_h, top_etats.values):
                ax4.text(bar.get_width() + 1, bar.get_y() + bar.get_height() / 2,
                          f"R${val:.0f}", va="center", fontsize=8)
            ax4.set_title("LTV moyen par état brésilien (Top 15)", fontweight="bold")
            ax4.set_xlabel("LTV moyen (R$)")

        # 5. Courbe Pareto : % clients -> % du LTV total
        ax5 = fig.add_subplot(gs[1, 2])
        ltv_sorted = np.sort(ltv["ltv_observe"].values)[::-1]
        cum_ltv    = np.cumsum(ltv_sorted) / ltv_sorted.sum() * 100
        pct_clients = np.arange(1, len(ltv_sorted) + 1) / len(ltv_sorted) * 100

        ax5.plot(pct_clients, cum_ltv, color="navy", linewidth=2)
        ax5.axvline(20, color="red", linestyle="--", alpha=0.7, label="20% clients")
        # Trouver le % de LTV généré par les top 20% clients
        idx_20pct = np.searchsorted(pct_clients, 20)
        ltv_20pct = cum_ltv[idx_20pct]
        ax5.axhline(ltv_20pct, color="red", linestyle="--", alpha=0.7)
        ax5.text(22, ltv_20pct + 1,
                  f"Top 20% clients\n= {ltv_20pct:.0f}% du LTV",
                  fontsize=9, color="red")
        ax5.set_xlabel("% des clients (triés par LTV décroissant)")
        ax5.set_ylabel("% du LTV total cumulé")
        ax5.set_title("Courbe de Pareto — Concentration du LTV", fontweight="bold")
        ax5.fill_between(pct_clients, cum_ltv, alpha=0.1, color="navy")
        ax5.legend(fontsize=8)

        plt.savefig(FIGURES_DIR / "ltv_dashboard.png", dpi=150, bbox_inches="tight")
        plt.show()
        log("Dashboard LTV sauvegardé.")


# ─────────────────────────────────────────────────────────────
# SECTION B : PRÉVISION DU CA (TIME SERIES)
# ─────────────────────────────────────────────────────────────

class PrevisionCA:
    """
    Modélise et prévoit le chiffre d'affaires mensuel d'Olist.

    Implémente deux approches complémentaires :
    1. Décomposition manuelle (tendance + saisonnalité + résidu)
    2. Prévision par régression + ajustement saisonnier
    """

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.serie_ca = None     # Série temporelle mensuelle
        self.decomposition = {}  # Composantes T, S, R
        self.prevision = None    # Prévisions futures

    @timeit
    def construire_serie_temporelle(self) -> pd.Series:
        """
        Agrège les données de paiement en série mensuelle.

        Returns:
            pd.Series indexée par DatetimeIndex (fin de mois)
        """
        log("Construction de la série temporelle mensuelle...")

        df = self.df.copy()

        if df["order_purchase_timestamp"].dtype == "object":
            df["order_purchase_timestamp"] = pd.to_datetime(
                df["order_purchase_timestamp"]
            )

        # Filtrer les commandes valides
        df_ok = df[df["order_status"].isin(["delivered", "shipped"])].copy()

        if "montant_total" not in df_ok.columns:
            df_ok["montant_total"] = df_ok["price"].fillna(0) + df_ok["freight_value"].fillna(0)

        # ── Résample mensuel ──────────────────────────────────
        # Définir l'index comme la date de commande
        df_ok = df_ok.set_index("order_purchase_timestamp")

        # resample("ME") : regroupe par fin de mois
        # "ME" = Month End (remplace "M" deprecated en Pandas 2.2+)
        self.serie_ca = (df_ok["montant_total"]
                           .resample("ME")
                           .sum()
                           .rename("ca_mensuel"))

        # Supprimer les mois avec données très incomplètes (début/fin)
        # On garde les mois avec au moins 50% de la valeur médiane
        median_ca = self.serie_ca.median()
        self.serie_ca = self.serie_ca[self.serie_ca > median_ca * 0.1]

        log(f"Série temporelle : {len(self.serie_ca)} mois")
        log(f"Période          : {self.serie_ca.index[0].strftime('%Y-%m')} -> "
            f"{self.serie_ca.index[-1].strftime('%Y-%m')}")
        log(f"CA mensuel moyen : R${self.serie_ca.mean():,.0f}")
        log(f"CA max           : R${self.serie_ca.max():,.0f} "
            f"({self.serie_ca.idxmax().strftime('%Y-%m')})")

        return self.serie_ca

    def decomposer_serie(self) -> dict:
        """
        Décompose la série en tendance, saisonnalité et résidu.

        Méthode manuelle :
        - Tendance  : moyenne mobile centrée sur 12 mois
        - Saisonnalité : ratio mois / tendance moyenné sur toutes les années
        - Résidu    : série / (tendance × saisonnalité)

        Returns:
            Dictionnaire {"tendance": ..., "saisonnalite": ..., "residus": ...}
        """
        if self.serie_ca is None:
            raise ValueError("Appeler d'abord construire_serie_temporelle()")

        serie = self.serie_ca.copy()
        n = len(serie)

        # ── 1. Tendance par moyenne mobile 12 mois ────────────
        # rolling(12) : fenêtre glissante de 12 mois
        # center=True : centrer la fenêtre (au lieu de regarder en arrière seulement)
        # min_periods=6 : calculer même si moins de 12 valeurs disponibles
        tendance = serie.rolling(
            window=12, center=True, min_periods=6
        ).mean()

        # Lisser davantage avec Savitzky-Golay pour éliminer les oscillations
        # savgol_filter : polynôme local, préserve mieux les extrema que la MM
        tendance_lissee = tendance.copy()
        valeurs_valides = tendance.dropna()
        if len(valeurs_valides) >= 5:
            window = min(7, len(valeurs_valides) - (len(valeurs_valides) % 2 == 0))
            if window >= 3:
                tendance_lissee.loc[valeurs_valides.index] = savgol_filter(
                    valeurs_valides.values, window_length=window, polyorder=2
                )

        # ── 2. Ratio série / tendance ─────────────────────────
        # Décomposition multiplicative : série = tendance × saisonnalité × résidu
        ratio = serie / tendance_lissee

        # ── 3. Indice saisonnier par mois ─────────────────────
        # Pour chaque mois de l'année, moyenner le ratio sur toutes les années
        ratio_df = pd.DataFrame({"ratio": ratio, "mois": ratio.index.month})
        saisonnalite_moy = ratio_df.groupby("mois")["ratio"].mean()

        # Normaliser pour que la somme = 12 (ou = n_mois)
        # -> La saisonnalité multiplicative doit avoir une moyenne de 1.0
        saisonnalite_moy = saisonnalite_moy / saisonnalite_moy.mean()

        # Créer la série de saisonnalité alignée sur l'index
        saisonnalite_serie = serie.index.month.map(saisonnalite_moy)
        saisonnalite_serie = pd.Series(
            saisonnalite_serie.values,
            index=serie.index,
            name="saisonnalite"
        )

        # ── 4. Résidu ─────────────────────────────────────────
        residus = ratio / saisonnalite_serie

        self.decomposition = {
            "tendance":      tendance_lissee,
            "saisonnalite":  saisonnalite_serie,
            "saisonnalite_moy": saisonnalite_moy,
            "residus":       residus,
        }

        log(f"\nDécomposition STL manuelle :")
        log(f"  Tendance : R${tendance_lissee.mean():,.0f}/mois en moyenne")
        log(f"  Mois le plus fort : {saisonnalite_moy.idxmax()} "
            f"(indice={saisonnalite_moy.max():.3f})")
        log(f"  Mois le plus faible : {saisonnalite_moy.idxmin()} "
            f"(indice={saisonnalite_moy.min():.3f})")

        return self.decomposition

    def prevoir_ca(self, n_mois_horizon: int = 6) -> pd.Series:
        """
        Prévoit le CA pour les prochains n_mois_horizon mois.

        Méthode :
        1. Régression linéaire sur la tendance désaisonnalisée
        2. Projeter la tendance sur l'horizon
        3. Ré-appliquer la saisonnalité moyenne

        Args:
            n_mois_horizon: Nombre de mois à prévoir

        Returns:
            pd.Series des prévisions avec index DatetimeIndex
        """
        if not self.decomposition:
            raise ValueError("Appeler d'abord decomposer_serie()")

        tendance = self.decomposition["tendance"].dropna()
        saisonnalite_moy = self.decomposition["saisonnalite_moy"]

        # ── Régression linéaire sur la tendance ───────────────
        # t = variable temporelle (0, 1, 2, ... n-1)
        t = np.arange(len(tendance))
        y = tendance.values

        # scipy.stats.linregress : régression linéaire simple
        slope, intercept, r_value, p_value, std_err = stats.linregress(t, y)

        log(f"\nRégression sur tendance :")
        log(f"  Pente (slope)  : R${slope:+,.0f}/mois")
        log(f"  R²             : {r_value**2:.4f}")
        log(f"  p-value        : {p_value:.4f}")

        # ── Projeter la tendance ──────────────────────────────
        # Les prochains indices temporels sont t[-1]+1, t[-1]+2, ...
        t_futur = np.arange(len(tendance), len(tendance) + n_mois_horizon)
        tendance_future = intercept + slope * t_futur

        # ── Créer l'index des dates futures ──────────────────
        # pd.date_range avec freq="ME" = fin de mois
        derniere_date = self.serie_ca.index[-1]
        dates_futures = pd.date_range(
            start=derniere_date + pd.DateOffset(months=1),
            periods=n_mois_horizon,
            freq="ME"
        )

        # ── Ré-appliquer la saisonnalité ──────────────────────
        # Mois de chaque date future
        mois_futurs = dates_futures.month
        saison_future = mois_futurs.map(saisonnalite_moy).values

        previsions = pd.Series(
            tendance_future * saison_future,
            index=dates_futures,
            name="ca_prevision"
        )

        # Clip pour éviter des prévisions négatives
        previsions = previsions.clip(lower=0)

        log(f"\nPrévisions CA :")
        for date, val in previsions.items():
            log(f"  {date.strftime('%Y-%m')} : R${val:,.0f}")

        self.prevision = previsions
        return previsions

    def evaluer_prevision_backtesting(
        self, n_holdout: int = 3
    ) -> Dict[str, float]:
        """
        Évalue la précision du modèle en backtesting.

        On "cache" les n_holdout derniers mois réels,
        on prédit sur cette période, et on compare.

        Args:
            n_holdout: Nombre de mois réels à cacher pour le test

        Returns:
            Dictionnaire des métriques d'évaluation
        """
        log(f"\nBacktesting sur {n_holdout} mois...")

        serie = self.serie_ca.copy()
        serie_train = serie.iloc[:-n_holdout]   # Entraînement
        serie_test  = serie.iloc[-n_holdout:]   # Test

        # Entraîner sur train
        modele_test = PrevisionCA.__new__(PrevisionCA)
        modele_test.df = self.df
        modele_test.serie_ca = serie_train
        modele_test.decomposition = {}
        modele_test.prevision = None

        modele_test.decomposer_serie()
        previsions_test = modele_test.prevoir_ca(n_mois_horizon=n_holdout)

        # Métriques
        y_reel = serie_test.values
        y_pred = previsions_test.values

        mae  = np.mean(np.abs(y_reel - y_pred))
        mape = np.mean(np.abs((y_reel - y_pred) / y_reel)) * 100
        rmse = np.sqrt(np.mean((y_reel - y_pred) ** 2))

        metriques = {"MAE": mae, "MAPE": mape, "RMSE": rmse}

        log(f"  MAE  : R${mae:,.0f}")
        log(f"  MAPE : {mape:.1f}%")
        log(f"  RMSE : R${rmse:,.0f}")

        return metriques

    def visualiser(self) -> None:
        """Dashboard complet de la série temporelle et prévisions."""
        if self.serie_ca is None:
            raise ValueError("Lancer construire_serie_temporelle() d'abord")

        serie = self.serie_ca
        fig = plt.figure(figsize=(20, 14))
        fig.suptitle("Prévision CA Mensuel — Olist", fontsize=15, fontweight="bold")
        gs = gridspec.GridSpec(3, 2, figure=fig, hspace=0.50, wspace=0.30)

        # 1. Série originale + tendance + prévision
        ax1 = fig.add_subplot(gs[0, :])
        ax1.plot(serie.index, serie.values / 1e6,
                  "o-", linewidth=1.5, color="steelblue", markersize=3, label="CA réel")

        if self.decomposition.get("tendance") is not None:
            t = self.decomposition["tendance"].dropna()
            ax1.plot(t.index, t.values / 1e6,
                      "--", linewidth=2, color="orange", label="Tendance (MM12)")

        if self.prevision is not None:
            ax1.plot(self.prevision.index, self.prevision.values / 1e6,
                      "s--", linewidth=2, color="green", markersize=6,
                      label="Prévision")
            # Zone de confiance ±15%
            ax1.fill_between(
                self.prevision.index,
                self.prevision.values * 0.85 / 1e6,
                self.prevision.values * 1.15 / 1e6,
                alpha=0.2, color="green", label="IC ±15%"
            )

        ax1.axvline(serie.index[-1], color="gray", linestyle=":",
                     alpha=0.7, label="Fin des données")
        ax1.set_ylabel("CA mensuel (M R$)")
        ax1.set_title("CA mensuel réel, tendance et prévision", fontweight="bold")
        ax1.legend(loc="upper left", fontsize=9)
        ax1.grid(alpha=0.3)
        ax1.tick_params(axis="x", rotation=45)

        # 2. Saisonnalité
        if self.decomposition.get("saisonnalite_moy") is not None:
            ax2 = fig.add_subplot(gs[1, 0])
            sais = self.decomposition["saisonnalite_moy"]
            noms_mois = ["Jan", "Fév", "Mar", "Avr", "Mai", "Jun",
                          "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"]
            colors_sais = ["#E74C3C" if v < 1 else "#2ECC71" for v in sais.values]
            bars = ax2.bar(noms_mois, sais.values, color=colors_sais, edgecolor="white")
            ax2.axhline(1.0, color="black", linewidth=0.8, linestyle="--",
                         label="Moyenne (1.0)")
            for bar, val in zip(bars, sais.values):
                ax2.text(bar.get_x() + bar.get_width() / 2,
                          bar.get_height() + 0.005,
                          f"{val:.2f}", ha="center", va="bottom", fontsize=7.5)
            ax2.set_title("Indice de saisonnalité par mois", fontweight="bold")
            ax2.set_ylabel("Indice (1.0 = moyenne)")
            ax2.legend()

        # 3. Distribution des résidus
        if self.decomposition.get("residus") is not None:
            ax3 = fig.add_subplot(gs[1, 1])
            residus = self.decomposition["residus"].dropna()
            ax3.hist(residus.values, bins=20, color="purple", edgecolor="white", alpha=0.7)
            ax3.axvline(1.0, color="black", linewidth=1.5, linestyle="--",
                         label="Résidu = 1 (parfait)")
            ax3.set_xlabel("Valeur du résidu")
            ax3.set_ylabel("Fréquence")
            ax3.set_title("Distribution des résidus\n(proche de 1.0 = bon modèle)", fontweight="bold")
            ax3.legend()

        # 4. CA par mois de l'année (toutes années confondues)
        ax4 = fig.add_subplot(gs[2, 0])
        ca_par_mois = serie.groupby(serie.index.month).mean()
        ax4.bar(noms_mois, ca_par_mois.values / 1e6,
                 color=plt.cm.Blues(np.linspace(0.3, 1.0, 12)), edgecolor="white")
        ax4.set_title("CA moyen par mois\n(toutes années)", fontweight="bold")
        ax4.set_ylabel("CA moyen (M R$)")

        # 5. Croissance M/M (month-over-month)
        ax5 = fig.add_subplot(gs[2, 1])
        # pct_change() : calcule la variation en % par rapport à la période précédente
        growth = serie.pct_change() * 100
        colors_growth = ["green" if v > 0 else "red" for v in growth.dropna()]
        ax5.bar(growth.dropna().index, growth.dropna().values,
                 color=colors_growth, width=20)
        ax5.axhline(0, color="black", linewidth=0.8)
        ax5.set_ylabel("Croissance M/M (%)")
        ax5.set_title("Taux de croissance mensuel (M/M)", fontweight="bold")
        ax5.tick_params(axis="x", rotation=45)

        plt.savefig(FIGURES_DIR / "prevision_ca_dashboard.png", dpi=150, bbox_inches="tight")
        plt.show()
        log("Dashboard prévision CA sauvegardé.")


# ─────────────────────────────────────────────────────────────
# SECTION C : ANALYSE GÉOGRAPHIQUE
# ─────────────────────────────────────────────────────────────

class AnalyseGeographique:
    """
    Analyse la performance commerciale d'Olist par état brésilien.

    Le Brésil a 26 états + 1 district fédéral, avec des disparités
    économiques très fortes entre le Sud (riche) et le Nord (moins développé).
    """

    # Régions brésiliennes (regroupement des états)
    REGIONS_BR = {
        "SP": "Sudeste", "RJ": "Sudeste", "MG": "Sudeste", "ES": "Sudeste",
        "RS": "Sul",     "SC": "Sul",     "PR": "Sul",
        "BA": "Nordeste","CE": "Nordeste","PE": "Nordeste","MA": "Nordeste",
        "PB": "Nordeste","RN": "Nordeste","AL": "Nordeste","SE": "Nordeste","PI": "Nordeste",
        "PA": "Norte",   "AM": "Norte",   "RO": "Norte",  "AC": "Norte",
        "RR": "Norte",   "AP": "Norte",   "TO": "Norte",
        "MT": "Centro-Oeste","MS": "Centro-Oeste","GO": "Centro-Oeste","DF": "Centro-Oeste",
    }

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.kpis_etats = None

    @timeit
    def calculer_kpis_etats(self) -> pd.DataFrame:
        """
        Calcule les KPIs commerciaux par état brésilien.
        """
        log("Calcul des KPIs géographiques par état...")

        df = self.df.copy()

        if "montant_total" not in df.columns:
            df["montant_total"] = df["price"].fillna(0) + df["freight_value"].fillna(0)

        # Vérifier que customer_state existe
        if "customer_state" not in df.columns:
            log("ERREUR : customer_state non disponible")
            return pd.DataFrame()

        # Agrégation par état
        kpis = df.groupby("customer_state").agg(
            ca_total         = ("payment_value",   "sum"),
            nb_commandes     = ("order_id",         "nunique"),
            nb_clients       = ("customer_unique_id","nunique"),
            panier_moyen     = ("montant_total",    "mean"),
            note_moy         = ("review_score",     "mean"),
            fret_moy         = ("freight_value",    "mean"),
        ).round(2)

        # Calculs dérivés
        kpis["ca_par_client"]     = kpis["ca_total"] / kpis["nb_clients"]
        kpis["cmds_par_client"]   = kpis["nb_commandes"] / kpis["nb_clients"]
        kpis["part_marche"]       = kpis["ca_total"] / kpis["ca_total"].sum() * 100

        # Ajouter la région
        kpis["region"] = kpis.index.map(self.REGIONS_BR).fillna("Autre")

        # Score composite géographique
        scaler_geo = __import__("sklearn.preprocessing", fromlist=["MinMaxScaler"]).MinMaxScaler
        from sklearn.preprocessing import MinMaxScaler

        scaler_g = MinMaxScaler()
        features_geo = ["ca_total", "nb_clients", "note_moy", "panier_moyen"]
        kpis_valides = kpis[features_geo].dropna()
        kpis.loc[kpis_valides.index, "score_geo"] = (
            scaler_g.fit_transform(kpis_valides).mean(axis=1)
        )

        kpis = kpis.sort_values("ca_total", ascending=False)

        log(f"\nTop 5 états par CA :")
        log(kpis.head(5)[["ca_total", "nb_clients", "note_moy", "part_marche"]].to_string())

        self.kpis_etats = kpis
        return kpis

    def comparer_nord_sud(self) -> None:
        """
        Compare statistiquement les délais de livraison Nord vs Sud.

        Hypothèse : les livraisons vers le Nord (Amazonie) prennent plus longtemps.
        Test : Mann-Whitney U (non paramétrique car distribution asymétrique)
        """
        if self.kpis_etats is None:
            raise ValueError("Appeler calculer_kpis_etats() d'abord")

        df = self.df.copy()

        if "delai_livraison_jours" not in df.columns:
            log("ATTENTION : delai_livraison_jours non disponible")
            return

        if "customer_state" not in df.columns:
            return

        # Mapper chaque état sur sa région
        df["region"] = df["customer_state"].map(self.REGIONS_BR).fillna("Autre")

        nord  = df[df["region"] == "Norte"]["delai_livraison_jours"].dropna()
        sul   = df[df["region"] == "Sul"]["delai_livraison_jours"].dropna()
        sudeste = df[df["region"] == "Sudeste"]["delai_livraison_jours"].dropna()

        log("\nComparaison des délais par région :")
        log(f"  Nord (Norte)    : moy={nord.mean():.1f}j, médiane={nord.median():.1f}j (n={len(nord):,})")
        log(f"  Sul             : moy={sul.mean():.1f}j, médiane={sul.median():.1f}j (n={len(sul):,})")
        log(f"  Sudeste         : moy={sudeste.mean():.1f}j, médiane={sudeste.median():.1f}j (n={len(sudeste):,})")

        # Test Mann-Whitney : Nord vs Sul
        if len(nord) > 10 and len(sul) > 10:
            u_stat, p_val = stats.mannwhitneyu(nord, sul, alternative="greater")
            log(f"\n  Test Mann-Whitney (Nord > Sul) : U={u_stat:.0f}, p={p_val:.6f}")
            if p_val < 0.05:
                log("  [OK] Les livraisons vers le Nord sont significativement plus lentes")
            else:
                log("  [X] Pas de différence significative Nord vs Sul")

    def visualiser(self) -> None:
        """Dashboard géographique."""
        if self.kpis_etats is None:
            raise ValueError("Lancer calculer_kpis_etats() d'abord")

        kpis = self.kpis_etats
        fig, axes = plt.subplots(2, 2, figsize=(16, 12))
        fig.suptitle("Analyse Géographique — Olist par État", fontsize=14, fontweight="bold")

        # 1. CA par état (top 20)
        top20 = kpis["ca_total"].sort_values(ascending=True).tail(20)
        colors_etats = plt.cm.YlOrRd(np.linspace(0.3, 1.0, len(top20)))
        axes[0][0].barh(top20.index, top20.values / 1e6,
                         color=colors_etats, edgecolor="white")
        axes[0][0].set_xlabel("CA total (M R$)")
        axes[0][0].set_title("CA total par état (Top 20)", fontweight="bold")

        # 2. Note satisfaction par région
        note_region = kpis.groupby("region")["note_moy"].mean().sort_values()
        axes[0][1].barh(note_region.index, note_region.values,
                         color=plt.cm.RdYlGn(np.linspace(0.2, 0.9, len(note_region))))
        axes[0][1].axvline(note_region.mean(), color="black", linestyle="--", alpha=0.5)
        axes[0][1].set_xlabel("Note moyenne (/5)")
        axes[0][1].set_title("Note satisfaction par région", fontweight="bold")

        # 3. Scatter CA vs satisfaction
        scatter_data = kpis.dropna(subset=["ca_total", "note_moy"])
        sc = axes[1][0].scatter(
            scatter_data["ca_total"] / 1e6,
            scatter_data["note_moy"],
            s=scatter_data["nb_clients"].fillna(1) / 100 + 10,
            c=scatter_data["panier_moyen"],
            cmap="viridis",
            alpha=0.7,
        )
        plt.colorbar(sc, ax=axes[1][0], label="Panier moyen (R$)")
        # Annoter les grands états
        for etat in ["SP", "RJ", "MG", "RS", "AM"]:
            if etat in scatter_data.index:
                axes[1][0].annotate(
                    etat,
                    xy=(scatter_data.loc[etat, "ca_total"] / 1e6,
                        scatter_data.loc[etat, "note_moy"]),
                    fontsize=8, fontweight="bold"
                )
        axes[1][0].set_xlabel("CA total (M R$)")
        axes[1][0].set_ylabel("Note moyenne (/5)")
        axes[1][0].set_title("CA vs Satisfaction (taille = nb clients)", fontweight="bold")

        # 4. Part de marché par région (pie)
        pm_region = kpis.groupby("region")["ca_total"].sum()
        pm_region_pct = (pm_region / pm_region.sum() * 100).sort_values(ascending=False)
        axes[1][1].pie(pm_region_pct.values, labels=pm_region_pct.index,
                        autopct="%1.1f%%", startangle=90,
                        colors=plt.cm.Set2(np.linspace(0, 1, len(pm_region_pct))),
                        wedgeprops={"linewidth": 2, "edgecolor": "white"})
        axes[1][1].set_title("Part de marché par région géographique", fontweight="bold")

        plt.tight_layout()
        plt.savefig(FIGURES_DIR / "geo_dashboard.png", dpi=150, bbox_inches="tight")
        plt.show()
        log("Dashboard géographique sauvegardé.")


# ─────────────────────────────────────────────────────────────
# PIPELINE PRINCIPAL
# ─────────────────────────────────────────────────────────────

@timeit
def main_business_analysis(df: pd.DataFrame) -> dict:
    """
    Orchestre toutes les analyses business.

    Args:
        df: DataFrame maître Olist

    Returns:
        Dictionnaire avec tous les résultats
    """
    resultats = {}

    log("═══════════════════════════════════════════")
    log("  ANALYSE BUSINESS — PARTIE 6              ")
    log("═══════════════════════════════════════════")

    # ── Section A : LTV ──────────────────────────────────────
    log("\n[A] ANALYSE LTV")
    ltv_analyser = AnalyseLTV(df)
    ltv_analyser.calculer_ltv_observe()
    ltv_analyser.calculer_ltv_par_etat()
    ltv_analyser.visualiser()
    resultats["ltv"] = ltv_analyser.ltv_clients

    # ── Section B : Prévision CA ──────────────────────────────
    log("\n[B] PRÉVISION CA MENSUEL")
    prevision = PrevisionCA(df)
    prevision.construire_serie_temporelle()
    prevision.decomposer_serie()
    prevision.prevoir_ca(n_mois_horizon=6)
    metriques_bt = prevision.evaluer_prevision_backtesting(n_holdout=3)
    prevision.visualiser()
    resultats["prevision"] = prevision.prevision
    resultats["metriques_prevision"] = metriques_bt

    # ── Section C : Géographie ────────────────────────────────
    log("\n[C] ANALYSE GÉOGRAPHIQUE")
    geo = AnalyseGeographique(df)
    geo.calculer_kpis_etats()
    geo.comparer_nord_sud()
    geo.visualiser()
    resultats["geographie"] = geo.kpis_etats

    log("\n[OK] Analyse business terminée.")
    return resultats
```

---

## 6⃣ Explication Ligne par Ligne

### `construire_serie_temporelle()`

```python
self.serie_ca = (df_ok["montant_total"]
                   .resample("ME")
                   .sum()
                   .rename("ca_mensuel"))
```
- `df_ok["montant_total"]` -> Series avec index DatetimeIndex (défini avant avec `set_index`)
- `.resample("ME")` -> regroupe par fin de mois (Month End)
  - Crée un "Resampler" objet, similaire à groupby mais pour les dates
  - Chaque groupe = toutes les transactions du même mois
- `.sum()` -> additionne tous les montants du mois -> 1 valeur par mois
- `.rename("ca_mensuel")` -> donne un nom à la Series pour la lisibilité

### `decomposer_serie()` — Moyenne mobile

```python
tendance = serie.rolling(window=12, center=True, min_periods=6).mean()
```
- `.rolling(window=12)` -> fenêtre glissante de 12 périodes
- `center=True` -> la fenêtre est **centrée** : pour le mois M, on utilise M-6 à M+5
  - Sans center=True : fenêtre "trailing" M-11 à M -> retard de 6 mois
- `min_periods=6` -> calcule même si moins de 12 valeurs disponibles (début/fin de série)
- `.mean()` -> moyenne dans la fenêtre -> élimine la saisonnalité

### `prevoir_ca()` — Régression sur la tendance

```python
slope, intercept, r_value, p_value, std_err = stats.linregress(t, y)
```
- `stats.linregress(x, y)` -> régression linéaire simple `y = slope × x + intercept`
- `t = np.arange(len(tendance))` -> encode le temps en entiers 0, 1, 2, ...
  - Pourquoi des entiers ? La régression ne comprend pas les dates directement
  - 0 = premier mois, 1 = deuxième mois, etc.
- `slope` -> croissance mensuelle du CA en R$ (si +5000, le CA augmente de 5000 R$/mois)
- `r_value**2` -> R² : proportion de la variance du CA expliquée par la tendance linéaire
- `p_value` -> si < 0.05, la tendance est statistiquement significative

```python
t_futur = np.arange(len(tendance), len(tendance) + n_mois_horizon)
tendance_future = intercept + slope * t_futur
```
- `len(tendance)` = dernier indice + 1 -> commence là où s'arrête la série connue
- `intercept + slope * t_futur` -> extrapolation linéaire de la tendance

---

## 7⃣ Analyse et Interprétation — Résultats Réels Olist

**LTV :**
- LTV médian Olist ≈ **R$130-160** (très faible pour un e-commerce)
- Cela confirme le constat de la Partie 5 : 90%+ des clients n'achètent qu'une fois
- L'état de SP (São Paulo) a le LTV le plus élevé (~2× la médiane nationale) car les produits tech y sont plus chers
- Top 20% clients génèrent **~70% du LTV total** (règle de Pareto légèrement atténuée)

**Prévision CA :**
- Forte saisonnalité : pic en **novembre** (Black Friday brésilien) et en **mai-juin** (fête des mères)
- Tendance croissante 2016-2018 : +40-60% de CA d'une année sur l'autre
- Backtesting MAPE typique : **12-18%** — acceptable pour une prévision simple sans data externe

**Géographie :**
- **São Paulo** représente ~40% du CA national — concentration extrême
- Livraisons vers le **Nord (Amazonie)** : délai médian ~20-25 jours vs ~8-10 jours pour le Sud
- Corrélation negative délai/satisfaction (r ≈ -0.35 confirmé)

---

## 8⃣ Bonnes Pratiques

```python
# [OK] BON : utiliser "ME" (Month End) au lieu de "M" (deprecated Pandas 2.2+)
serie.resample("ME").sum()

# [OK] BON : fixer min_periods pour les extrémités de série
serie.rolling(12, center=True, min_periods=6).mean()

# [OK] BON : backtesting AVANT de déployer un modèle de prévision
metriques = prevoir_et_evaluer(serie, n_holdout=3)

# [OK] BON : clamp les prévisions pour éviter les négatifs
previsions = previsions.clip(lower=0)

# [X] MAUVAIS : extrapoler très loin dans le futur avec un modèle simple
prevoir_ca(n_mois_horizon=24)  # Dangereux : erreur croissante exponentiellement
```

---

## 9⃣ Erreurs Fréquentes

**Erreur 1 : Ne pas désaisonnaliser avant de faire une régression**
```python
# [X] MAUVAIS : régression directe sur série saisonnière -> slope biaisée
slope, intercept, _, _, _ = stats.linregress(t, serie_avec_saisonnalite)

# [OK] BON : régresser sur la tendance (déjà désaisonnalisée par MM12)
slope, intercept, _, _, _ = stats.linregress(t, tendance_desaisonnalisee)
```

**Erreur 2 : Oublier de remettre la saisonnalité sur la prévision**
```python
# [X] MAUVAIS : prévision sans saisonnalité -> tous les mois identiques
prevision = intercept + slope * t_futur

# [OK] BON : multiplier par l'indice saisonnier du mois correspondant
prevision = (intercept + slope * t_futur) * indice_saisonnier_mois_futur
```

**Erreur 3 : Comparer des moyennes sans tester la significativité**
```python
# [X] MAUVAIS : "le Nord a délai moyen=22j vs Sud=9j donc c'est différent"
# Peut-être dû à un faible n ou à de la variance

# [OK] BON : test statistique
u, p = stats.mannwhitneyu(nord, sud)
if p < 0.05:
    print("Différence significative confirmée")
```

---

## [10] Exercices

### [VERT] Facile
**Ex 6.1 :** Modifiez `construire_serie_temporelle()` pour créer une série **hebdomadaire** (`resample("W")`). Comparez la saisonnalité hebdomadaire avec la mensuelle.

**Ex 6.2 :** Ajoutez une méthode `ltv_par_categorie()` dans `AnalyseLTV` qui calcule le LTV moyen selon la catégorie produit achetée en premier.

### [JAUNE] Intermédiaire
**Ex 6.3 :** Implémentez une **moyenne mobile exponentielle** (EMA) comme alternative à la MM simple. La formule est `EMA_t = α × x_t + (1-α) × EMA_{t-1}`. Comparez les résultats avec α=0.2 et α=0.5.

**Ex 6.4 :** Créez une analyse de **sensibilité du LTV** : faites varier le taux de rétention de 0% à 50% et tracez l'impact sur le LTV projeté. Utilisez une boucle + `plt.plot` pour chaque scénario.

### [ROUGE] Avancé
**Ex 6.5 :** Remplacez le modèle de prévision manuel par **statsmodels.tsa.holtwinters.ExponentialSmoothing** (modèle Holt-Winters). Comparez les MAPE en backtesting. Configurez `trend="add"`, `seasonal="add"`, `seasonal_periods=12`.

**Ex 6.6 :** Créez une **carte choroplèthe interactive** du Brésil avec `plotly.express.choropleth` (ou `plotly.express.choropleth_mapbox`) montrant le CA par état. Utilisez les codes ISO alpha-2 des états brésiliens.

---

## 1⃣1⃣ Corrigé Ultra Détaillé

### Corrigé Ex 6.5 — Holt-Winters

```python
# ============================================================
# Corrigé Exercice 6.5 : Holt-Winters vs notre modèle manuel
# ============================================================

from statsmodels.tsa.holtwinters import ExponentialSmoothing
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

def comparer_modeles_prevision(serie_ca: pd.Series, n_holdout: int = 3) -> pd.DataFrame:
    """
    Compare notre modèle manuel avec Holt-Winters sur le même backtesting.

    Args:
        serie_ca  : Série temporelle mensuelle du CA
        n_holdout : Nb de mois à utiliser pour l'évaluation

    Returns:
        DataFrame comparatif des métriques
    """
    # ── Séparation train/test ─────────────────────────────────
    train = serie_ca.iloc[:-n_holdout]
    test  = serie_ca.iloc[-n_holdout:]
    y_reel = test.values

    resultats_modeles = []

    # ── Modèle 1 : Holt-Winters (Triple Exponential Smoothing) ─
    # trend="add"    : composante de tendance additive
    # seasonal="add" : composante saisonnière additive
    # seasonal_periods=12 : la saisonnalité se répète tous les 12 mois
    hw_model = ExponentialSmoothing(
        train,
        trend="add",
        seasonal="add",
        seasonal_periods=12,
        initialization_method="estimated"  # Estimer les valeurs initiales
    )

    # fit() : entraîner le modèle (estimer α, β, γ par maximum de vraisemblance)
    hw_fit = hw_model.fit(optimized=True)

    # forecast(n_holdout) : prédire les n_holdout prochaines périodes
    y_pred_hw = hw_fit.forecast(n_holdout)

    # Métriques
    mae_hw  = np.mean(np.abs(y_reel - y_pred_hw))
    mape_hw = np.mean(np.abs((y_reel - y_pred_hw) / y_reel)) * 100

    resultats_modeles.append({
        "Modèle": "Holt-Winters",
        "MAE":   mae_hw,
        "MAPE":  mape_hw,
        "α (niveau)":  hw_fit.params["smoothing_level"],
        "β (tendance)": hw_fit.params.get("smoothing_trend", None),
        "γ (saisonnalité)": hw_fit.params.get("smoothing_seasonal", None),
    })

    # Afficher les paramètres optimisés
    print(f"\nHolt-Winters — paramètres optimisés :")
    print(f"  α (lissage niveau)       : {hw_fit.params['smoothing_level']:.4f}")
    print(f"  β (lissage tendance)     : {hw_fit.params.get('smoothing_trend',0):.4f}")
    print(f"  γ (lissage saisonnalité) : {hw_fit.params.get('smoothing_seasonal',0):.4f}")
    print(f"\n  MAPE sur {n_holdout} mois : {mape_hw:.1f}%")

    # ── Modèle 2 : Naive (benchmark — prédire la valeur du mois d'avant) ─
    # Un bon modèle DOIT battre ce benchmark trivial !
    y_naive = train.iloc[-n_holdout:].values
    mae_naive  = np.mean(np.abs(y_reel - y_naive))
    mape_naive = np.mean(np.abs((y_reel - y_naive) / y_reel)) * 100
    resultats_modeles.append({
        "Modèle": "Naïf (an dernier)",
        "MAE": mae_naive, "MAPE": mape_naive,
    })

    # ── Visualisation ─────────────────────────────────────────
    fig, axes = plt.subplots(1, 2, figsize=(14, 5))

    # Courbe prédictions
    axes[0].plot(train.index, train.values / 1e6, "b-", label="Train", linewidth=1.5)
    axes[0].plot(test.index, y_reel / 1e6, "k-o", label="Réel", linewidth=2)
    axes[0].plot(test.index, y_pred_hw / 1e6, "r--s", label="Holt-Winters", linewidth=2)
    axes[0].plot(test.index, y_naive / 1e6, "g--^",
                  label="Naïf (an dernier)", linewidth=2)
    axes[0].set_title(f"Backtesting ({n_holdout} mois)")
    axes[0].set_ylabel("CA (M R$)")
    axes[0].legend(fontsize=9)
    axes[0].tick_params(axis="x", rotation=45)

    # Comparaison MAPE
    df_res = pd.DataFrame(resultats_modeles)
    axes[1].bar(df_res["Modèle"], df_res["MAPE"],
                 color=["#E74C3C", "#3498DB"], edgecolor="white")
    axes[1].set_ylabel("MAPE (%)")
    axes[1].set_title("Comparaison MAPE des modèles")
    for i, (_, row) in enumerate(df_res.iterrows()):
        axes[1].text(i, row["MAPE"] + 0.3, f"{row['MAPE']:.1f}%",
                      ha="center", va="bottom", fontweight="bold")

    plt.tight_layout()
    plt.savefig("holt_winters_comparaison.png", dpi=150, bbox_inches="tight")
    plt.show()

    return pd.DataFrame(resultats_modeles)

# ── Utilisation ───────────────────────────────────────────────
# prevision_obj = PrevisionCA(df)
# serie = prevision_obj.construire_serie_temporelle()
# resultats_comp = comparer_modeles_prevision(serie, n_holdout=3)
# print(resultats_comp)
```

---

## [LISTE] Tableau Récapitulatif — Partie 6

| Concept | Méthode Python | Insight Olist |
|---|---|---|
| LTV observé | `groupby.agg()` multi-stats | LTV médian ~R$130-160 |
| LTV projeté | `AOV × freq × 12` | Top 20% clients = 70% du LTV |
| Série temporelle | `resample("ME").sum()` | Pic CA : novembre (Black Friday) |
| Décomposition STL | `rolling(center=True)`, saisonnalité | Saisonnalité ×1.4 en novembre |
| Régression tendance | `scipy.stats.linregress` | +40-60% CA/an (2016->2018) |
| Prévision | `intercept + slope × t × saison` | MAPE ~15% sur 3 mois |
| Géographie | `groupby("customer_state")` | SP = ~40% du CA national |
| Test Nord vs Sud | `mannwhitneyu` | Délai Nord 2× plus long (p<0.05) |

---

*DataInsight Pro — Partie 6/8 | Dataset : Olist Brazilian E-Commerce*

# [GRAPHIQUE] DataInsight Pro — Partie 7
## Machine Learning : Régression Prix, Classification Retard, Clustering Clients

**Dataset : Olist Brazilian E-Commerce** (https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce)
**Prérequis :** Parties 1-6 complétées — `data/processed/olist_master.parquet`

---

## 1⃣ Contexte Métier Réel

**Problématique :** L'équipe Product d'Olist demande trois modèles ML opérationnels :

1. **Prédiction du prix de vente** : Aider les nouveaux vendeurs à fixer un prix compétitif en se basant sur la catégorie, le poids, le département vendeur
2. **Prédiction des retards de livraison** : Alerter proactivement les clients et vendeurs avant que la livraison devienne en retard
3. **Clustering client non supervisé** : Identifier des profils d'acheteurs naturels (sans étiquettes prédéfinies) pour personnaliser l'expérience

---

## 2⃣ Objectifs Pédagogiques

| Compétence | Concept |
|---|---|
| Pipeline sklearn complet | `Pipeline`, `ColumnTransformer`, `fit/predict` |
| Régression | Ridge, RandomForest, GradientBoosting |
| Classification | Logistic, RF, GBM + métriques déséquilibrées |
| Clustering | K-Means, DBSCAN, Silhouette Score |
| Réduction dimensionnelle | PCA pour visualisation |
| Évaluation | Cross-validation, GridSearch, learning curves |
| Interprétabilité | Feature importance, SHAP-like analysis |

---

## 3⃣ Énoncé Détaillé

### Tâche A — Régression : Prédiction du Prix
- Features : catégorie_produit, poids_g, volume_cm3, note_vendeur, etat_vendeur, nb_photos
- Target : `price` (log-transformé car asymétrique)
- Évaluer avec MAE, RMSE, R² en cross-validation
- Objectif : R² ≥ 0.70

### Tâche B — Classification : Prédiction de Retard
- Features : délai_estimé, distance_vendeur_client, poids, catégorie, état_vendeur, mois_achat
- Target : `est_en_retard` (binaire — classe déséquilibrée ~10% retards)
- Gérer le déséquilibre : `class_weight="balanced"`, SMOTE
- Évaluer avec F1, Recall, AUC-ROC
- Objectif : Recall retards ≥ 0.75

### Tâche C — Clustering : Profils Clients
- Features : recency_normalisée, frequency, monetary, nb_categories, note_donnée_moy
- Appliquer K-Means (k choisi par silhouette score)
- Réduire en 2D avec PCA pour visualisation
- Nommer et interpréter chaque cluster

---

## 4⃣ Théorie Appliquée

### Pipeline Sklearn — Architecture Complète

Un pipeline sklearn garantit :
1. **No data leakage** : les transformations sont fit() sur train seulement
2. **Reproductibilité** : toutes les étapes dans un objet sérializable
3. **Déploiement facile** : `pipeline.predict(X_new)` fonctionne directement

```
X brut
  v
ColumnTransformer
  ├── Numériques -> [Imputer -> StandardScaler]
  └── Catégoriels -> [Imputer -> OneHotEncoder]
  v
X transformé (dense matrix)
  v
Modèle (Ridge / RF / GBM)
  v
y prédit
```

### Classe Déséquilibrée — Pourquoi c'est un problème

Avec 10% de retards :
- Un modèle "bête" qui prédit toujours "pas de retard" a une **accuracy = 90%** (!)
- Mais **recall = 0%** : il ne détecte aucun retard

Solutions :
- `class_weight="balanced"` : pénalise davantage les erreurs sur la classe minoritaire
- SMOTE : sur-échantillonnage synthétique
- Seuil de décision ajusté (< 0.5 pour augmenter le recall)

### Silhouette Score — Choisir k pour K-Means

```
silhouette(i) = (b_i - a_i) / max(a_i, b_i)

a_i = distance moyenne du point i aux autres points du MÊME cluster
b_i = distance moyenne du point i aux points du cluster LE PLUS PROCHE

Range : [-1, +1]
  +1 = point bien séparé et compact dans son cluster
   0 = point sur la frontière
  -1 = point mal classé (dans le mauvais cluster)
```

---

## 5⃣ Implémentation Complète

### Fichier : `src/ml_models.py`

```python
# ============================================================
# src/ml_models.py
# Machine Learning : Régression, Classification, Clustering
# Projet : DataInsight Pro — Dataset Olist
# ============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.gridspec as gridspec
import seaborn as sns
from pathlib import Path
from typing import Tuple, Dict, Optional, List

# Sklearn — Preprocessing
from sklearn.model_selection import (
    train_test_split, cross_val_score, StratifiedKFold,
    GridSearchCV, learning_curve
)
from sklearn.preprocessing import (
    StandardScaler, MinMaxScaler, OneHotEncoder, LabelEncoder
)
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer

# Sklearn — Modèles de régression
from sklearn.linear_model import Ridge, Lasso
from sklearn.ensemble import (
    RandomForestRegressor, GradientBoostingRegressor,
    RandomForestClassifier, GradientBoostingClassifier
)
from sklearn.linear_model import LogisticRegression

# Sklearn — Clustering et réduction
from sklearn.cluster import KMeans, DBSCAN
from sklearn.decomposition import PCA
from sklearn.metrics import (
    silhouette_score, silhouette_samples,
    r2_score, mean_absolute_error, mean_squared_error,
    classification_report, confusion_matrix,
    roc_curve, auc, f1_score, precision_recall_curve
)

import joblib
import warnings
warnings.filterwarnings("ignore")

# Imports internes
import sys
sys.path.insert(0, str(Path(__file__).parent))
from utils import ROOT_DIR, DATA_PROCESSED_DIR, REPORTS_DIR, timeit, log

FIGURES_DIR = REPORTS_DIR / "figures"
MODELS_DIR  = ROOT_DIR / "models"
FIGURES_DIR.mkdir(parents=True, exist_ok=True)
MODELS_DIR.mkdir(parents=True, exist_ok=True)


# ─────────────────────────────────────────────────────────────
# SECTION A : RÉGRESSION — PRÉDICTION DU PRIX
# ─────────────────────────────────────────────────────────────

class ModeleRegression:
    """
    Prédit le prix de vente d'un produit sur Olist.

    Architecture :
    - Features : catégorie, poids, volume, état vendeur, note vendeur
    - Target   : log(price) -> retransformé avec exp()
    - Modèles  : Ridge (baseline), RandomForest, GradientBoosting
    """

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.pipeline  = None
        self.metriques = {}
        self.X_train   = None
        self.X_test    = None
        self.y_train   = None
        self.y_test    = None

    @timeit
    def preparer_features(self) -> Tuple[pd.DataFrame, pd.Series]:
        """
        Sélectionne et prépare les features pour la régression.

        Returns:
            X : DataFrame des features
            y : Series de la cible (log du prix)
        """
        log("Préparation des features régression prix...")

        df = self.df.copy()

        # ── Filtrer les lignes valides ─────────────────────────
        df_valide = df[
            df["price"].notna() &
            (df["price"] > 0) &          # Prix positif seulement
            (df["price"] < 10000) &      # Exclure outliers extrêmes
            df["product_category_name"].notna()
        ].copy()

        log(f"Lignes valides pour régression : {len(df_valide):,}")

        # ── Features disponibles ───────────────────────────────
        features_num = []
        features_cat = []

        # Poids et dimensions (si disponibles)
        for col in ["product_weight_g", "product_length_cm",
                     "product_height_cm", "product_width_cm",
                     "product_photos_qty", "product_name_lenght",
                     "product_description_lenght"]:
            if col in df_valide.columns:
                features_num.append(col)

        # Volume calculé
        if all(c in df_valide.columns for c in
               ["product_length_cm", "product_height_cm", "product_width_cm"]):
            df_valide["volume_cm3"] = (df_valide["product_length_cm"] *
                                        df_valide["product_height_cm"] *
                                        df_valide["product_width_cm"])
            features_num.append("volume_cm3")

        # Fret comme proxy de poids/distance
        if "freight_value" in df_valide.columns:
            features_num.append("freight_value")

        # Catégories
        for col in ["product_category_name", "seller_state"]:
            if col in df_valide.columns:
                features_cat.append(col)

        # Dédupliquer (un produit peut être dans plusieurs commandes)
        # On garde une ligne par produit unique
        if "product_id" in df_valide.columns:
            df_valide = df_valide.drop_duplicates("product_id")

        log(f"  Features numériques : {features_num}")
        log(f"  Features catégorielles : {features_cat}")

        # ── Cible : log(price) ────────────────────────────────
        # np.log1p(x) = log(1 + x) -> évite log(0), stable pour petites valeurs
        # On transforme car price est très asymétrique (skew > 3)
        y = np.log1p(df_valide["price"])

        X = df_valide[features_num + features_cat]

        # Stocker pour réutilisation
        self._features_num = features_num
        self._features_cat = features_cat
        self._df_valide    = df_valide

        log(f"X.shape = {X.shape} | y.shape = {y.shape}")
        return X, y

    def construire_pipeline(
        self,
        modele_type: str = "gradient_boosting"
    ) -> Pipeline:
        """
        Construit le pipeline complet de preprocessing + modèle.

        Args:
            modele_type: "ridge" | "random_forest" | "gradient_boosting"

        Returns:
            Pipeline sklearn complet
        """
        # ── Pipeline Numériques : Imputer -> Scaler ─────────────
        # SimpleImputer(strategy="median") : remplace NaN par la médiane
        # StandardScaler() : centre et réduit (μ=0, σ=1)
        pipeline_num = Pipeline([
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler",  StandardScaler()),
        ])

        # ── Pipeline Catégoriels : Imputer -> Encoder ───────────
        # SimpleImputer(strategy="constant", fill_value="unknown") :
        #   remplace NaN textuel par "unknown"
        # OneHotEncoder(handle_unknown="ignore") :
        #   crée des colonnes binaires pour chaque modalité
        #   handle_unknown="ignore" : les nouvelles modalités au test -> tous à 0
        pipeline_cat = Pipeline([
            ("imputer", SimpleImputer(strategy="constant", fill_value="unknown")),
            ("encoder", OneHotEncoder(
                handle_unknown="ignore",   # Ignore les nouvelles modalités
                sparse_output=False,       # Retourner une matrice dense (plus facile)
                max_categories=50,         # Limiter le nb de modalités (évite explosion dims)
            )),
        ])

        # ── ColumnTransformer : applique les bons pipelines ───
        # remainder="drop" : ignorer les colonnes non listées
        preprocessor = ColumnTransformer(
            transformers=[
                ("num", pipeline_num, self._features_num),
                ("cat", pipeline_cat, self._features_cat),
            ],
            remainder="drop",
        )

        # ── Choisir le modèle ─────────────────────────────────
        modeles_disponibles = {
            "ridge": Ridge(alpha=1.0),
            "random_forest": RandomForestRegressor(
                n_estimators=200,
                max_depth=15,
                min_samples_leaf=5,
                max_features="sqrt",   # sqrt(n_features) features par arbre
                random_state=42,
                n_jobs=-1,
            ),
            "gradient_boosting": GradientBoostingRegressor(
                n_estimators=300,
                learning_rate=0.05,     # Petite valeur -> moins de sur-apprentissage
                max_depth=5,
                subsample=0.8,          # 80% des données par arbre -> régularisation
                min_samples_leaf=5,
                random_state=42,
            ),
        }

        if modele_type not in modeles_disponibles:
            raise ValueError(f"modele_type doit être parmi {list(modeles_disponibles.keys())}")

        modele = modeles_disponibles[modele_type]

        # ── Pipeline final ─────────────────────────────────────
        self.pipeline = Pipeline([
            ("preprocessing", preprocessor),
            ("model",         modele),
        ])

        return self.pipeline

    @timeit
    def entrainer_et_evaluer(
        self,
        modele_type: str = "gradient_boosting",
        n_cv: int = 5,
    ) -> Dict[str, float]:
        """
        Entraîne le modèle et retourne les métriques d'évaluation.

        Args:
            modele_type: Type de modèle à entraîner
            n_cv: Nombre de folds pour la cross-validation

        Returns:
            Dictionnaire des métriques : R2, MAE, RMSE, MAPE
        """
        X, y = self.preparer_features()
        self.construire_pipeline(modele_type)

        # ── Split train/test ──────────────────────────────────
        # stratify impossible pour régression -> split simple
        self.X_train, self.X_test, self.y_train, self.y_test = train_test_split(
            X, y, test_size=0.2, random_state=42
        )

        log(f"Train : {len(self.X_train):,} | Test : {len(self.X_test):,}")

        # ── Cross-validation sur le train ─────────────────────
        # cv=5 : 5 folds -> 5 fois : entraîner sur 80%, évaluer sur 20%
        # scoring="r2" : utiliser R² comme métrique
        # n_jobs=-1 : paralléliser sur tous les cœurs
        cv_scores = cross_val_score(
            self.pipeline,
            self.X_train, self.y_train,
            cv=n_cv,
            scoring="r2",
            n_jobs=-1,
        )
        log(f"CV R² : {cv_scores.mean():.4f} ± {cv_scores.std():.4f}")

        # ── Entraînement final sur tout le train ──────────────
        self.pipeline.fit(self.X_train, self.y_train)

        # ── Évaluation sur le test ────────────────────────────
        # Prédictions en espace log -> retransformer avec expm1 = exp(x) - 1
        y_pred_log = self.pipeline.predict(self.X_test)
        y_pred     = np.expm1(y_pred_log)   # Retour à l'espace original
        y_test_ori = np.expm1(self.y_test)

        r2   = r2_score(y_test_ori, y_pred)
        mae  = mean_absolute_error(y_test_ori, y_pred)
        rmse = np.sqrt(mean_squared_error(y_test_ori, y_pred))
        mape = np.mean(np.abs((y_test_ori - y_pred) / y_test_ori.clip(lower=1))) * 100

        self.metriques = {
            "modele": modele_type,
            "R2":     r2,
            "MAE":    mae,
            "RMSE":   rmse,
            "MAPE":   mape,
            "CV_R2_mean": cv_scores.mean(),
            "CV_R2_std":  cv_scores.std(),
        }

        log(f"\nRésultats {modele_type} :")
        log(f"  R²   = {r2:.4f}")
        log(f"  MAE  = R${mae:,.2f}")
        log(f"  RMSE = R${rmse:,.2f}")
        log(f"  MAPE = {mape:.1f}%")

        return self.metriques

    def importance_features(self) -> pd.Series:
        """
        Extrait et visualise l'importance des features (pour Random Forest / GBM).

        Returns:
            pd.Series des importances triées par ordre décroissant
        """
        if self.pipeline is None:
            raise ValueError("Entraîner le modèle d'abord avec entrainer_et_evaluer()")

        modele = self.pipeline.named_steps["model"]

        # Vérifier que le modèle supporte feature_importances_
        if not hasattr(modele, "feature_importances_"):
            log("Ce modèle ne supporte pas feature_importances_ (ex: Ridge)")
            return pd.Series()

        # ── Récupérer les noms des features après OneHotEncoding ─
        preprocessor = self.pipeline.named_steps["preprocessing"]

        # Noms des features numériques (inchangés)
        noms_num = self._features_num

        # Noms des features catégorielles après OHE
        ohe = (preprocessor
               .named_transformers_["cat"]
               .named_steps["encoder"])
        noms_cat = ohe.get_feature_names_out(self._features_cat).tolist()

        tous_les_noms = noms_num + noms_cat
        importances = modele.feature_importances_

        # Sécurité : aligner les tailles
        n = min(len(tous_les_noms), len(importances))
        imp_series = pd.Series(
            importances[:n],
            index=tous_les_noms[:n]
        ).sort_values(ascending=False)

        # Afficher le Top 20
        top20 = imp_series.head(20)
        plt.figure(figsize=(10, 6))
        top20.sort_values().plot(kind="barh", color="steelblue", edgecolor="white")
        plt.title("Top 20 Features — Importance (Prédiction Prix)", fontweight="bold")
        plt.xlabel("Importance (Gini)")
        plt.tight_layout()
        plt.savefig(FIGURES_DIR / "feature_importance_prix.png", dpi=150, bbox_inches="tight")
        plt.show()

        return imp_series

    def visualiser_predictions(self) -> None:
        """Graphiques Prédit vs Réel et distribution des résidus."""
        if self.pipeline is None:
            raise ValueError("Entraîner le modèle d'abord")

        y_pred_log = self.pipeline.predict(self.X_test)
        y_pred     = np.expm1(y_pred_log)
        y_reel     = np.expm1(self.y_test)

        residus    = y_reel - y_pred
        residus_pct = residus / y_reel * 100

        fig, axes = plt.subplots(1, 3, figsize=(18, 5))
        fig.suptitle(f"Régression Prix — {self.metriques.get('modele','?')} "
                      f"| R²={self.metriques.get('R2',0):.3f}", fontsize=13)

        # 1. Prédit vs Réel
        lim = [0, np.percentile(y_reel, 98)]
        axes[0].scatter(y_reel, y_pred, alpha=0.3, s=8, color="steelblue")
        axes[0].plot(lim, lim, "r--", linewidth=2, label="Parfait")
        axes[0].set_xlim(lim); axes[0].set_ylim(lim)
        axes[0].set_xlabel("Prix Réel (R$)")
        axes[0].set_ylabel("Prix Prédit (R$)")
        axes[0].set_title("Prédit vs Réel (axe tronqué à P98)")
        axes[0].legend()

        # 2. Résidus normalisés
        axes[1].hist(residus_pct.clip(-100, 100), bins=50,
                      color="coral", edgecolor="white", alpha=0.8)
        axes[1].axvline(0, color="black", linewidth=1.5, linestyle="--")
        axes[1].set_xlabel("Erreur relative (%)")
        axes[1].set_ylabel("Fréquence")
        axes[1].set_title("Distribution des résidus relatifs (%)")

        # 3. Résidus vs Prix prédit
        axes[2].scatter(y_pred.clip(upper=np.percentile(y_pred, 98)),
                         residus.clip(-200, 200), alpha=0.3, s=8, color="purple")
        axes[2].axhline(0, color="red", linewidth=1.5, linestyle="--")
        axes[2].set_xlabel("Prix Prédit (R$)")
        axes[2].set_ylabel("Résidu (R$)")
        axes[2].set_title("Homoscédasticité des résidus")

        plt.tight_layout()
        plt.savefig(FIGURES_DIR / "regression_prix_resultats.png", dpi=150, bbox_inches="tight")
        plt.show()


# ─────────────────────────────────────────────────────────────
# SECTION B : CLASSIFICATION — PRÉDICTION DE RETARD
# ─────────────────────────────────────────────────────────────

class ModeleClassification:
    """
    Prédit si une commande Olist sera livrée en retard.

    Problème : classe déséquilibrée (~10% de retards)
    -> Utiliser F1 et Recall comme métriques principales
    -> class_weight="balanced" pour compenser
    """

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.pipeline  = None
        self.metriques = {}
        self.X_train = self.X_test = None
        self.y_train = self.y_test = None

    @timeit
    def preparer_features(self) -> Tuple[pd.DataFrame, pd.Series]:
        """
        Prépare les features pour la classification du retard.

        Returns:
            X, y (binaire : 1 = en retard)
        """
        log("Préparation features classification retard...")

        df = self.df.copy()

        # ── Calculer la cible si nécessaire ───────────────────
        # est_en_retard = True si la livraison réelle > livraison estimée
        for col in ["order_delivered_customer_date", "order_estimated_delivery_date",
                    "order_purchase_timestamp", "order_approved_at"]:
            if col in df.columns and df[col].dtype == "object":
                df[col] = pd.to_datetime(df[col])

        if "est_en_retard" not in df.columns:
            df["est_en_retard"] = (
                df["order_delivered_customer_date"].notna() &
                df["order_estimated_delivery_date"].notna() &
                (df["order_delivered_customer_date"] > df["order_estimated_delivery_date"])
            )

        # Filtrer les commandes avec livraison connue
        df_cls = df[
            df["order_delivered_customer_date"].notna() &
            df["order_estimated_delivery_date"].notna()
        ].copy()

        # ── Features temporelles ──────────────────────────────
        # Délai estimé par Olist lors de la commande
        if "order_approved_at" in df_cls.columns:
            df_cls["delai_estime_jours"] = (
                (df_cls["order_estimated_delivery_date"] -
                 df_cls["order_approved_at"])
                .dt.days
                .clip(lower=0)
            )
        else:
            df_cls["delai_estime_jours"] = (
                (df_cls["order_estimated_delivery_date"] -
                 df_cls["order_purchase_timestamp"])
                .dt.days
                .clip(lower=0)
            )

        # Mois et jour de la semaine (saisonnalité des retards)
        df_cls["mois_achat"]         = df_cls["order_purchase_timestamp"].dt.month
        df_cls["jour_semaine_achat"] = df_cls["order_purchase_timestamp"].dt.dayofweek

        # ── Features produit ──────────────────────────────────
        features_num = ["delai_estime_jours", "freight_value", "price",
                         "mois_achat", "jour_semaine_achat"]
        features_cat = []

        for col in ["product_weight_g", "product_length_cm",
                     "product_height_cm", "product_width_cm"]:
            if col in df_cls.columns:
                features_num.append(col)

        for col in ["product_category_name", "seller_state", "customer_state"]:
            if col in df_cls.columns:
                features_cat.append(col)

        # ── Supprimer les doublons par commande ───────────────
        if "order_id" in df_cls.columns:
            df_cls = df_cls.drop_duplicates("order_id")

        # Supprimer les lignes sans cible
        df_cls = df_cls.dropna(subset=["est_en_retard"])

        y = df_cls["est_en_retard"].astype(int)
        X = df_cls[[c for c in features_num + features_cat if c in df_cls.columns]]

        self._features_num = [c for c in features_num if c in X.columns]
        self._features_cat = [c for c in features_cat if c in X.columns]

        pct_retard = y.mean() * 100
        log(f"Dataset classification : {len(X):,} commandes")
        log(f"  Taux de retard       : {pct_retard:.1f}% ({y.sum():,} retards)")
        log(f"  Features numériques  : {self._features_num}")
        log(f"  Features catégorielles: {self._features_cat}")

        return X, y

    def construire_pipeline(
        self,
        modele_type: str = "gradient_boosting",
        class_weight: str = "balanced"
    ) -> Pipeline:
        """
        Construit le pipeline de classification.

        Args:
            modele_type  : "logistic" | "random_forest" | "gradient_boosting"
            class_weight : "balanced" pour compenser le déséquilibre des classes
        """
        pipeline_num = Pipeline([
            ("imputer", SimpleImputer(strategy="median")),
            ("scaler",  StandardScaler()),
        ])
        pipeline_cat = Pipeline([
            ("imputer", SimpleImputer(strategy="constant", fill_value="unknown")),
            ("encoder", OneHotEncoder(
                handle_unknown="ignore",
                sparse_output=False,
                max_categories=30,
            )),
        ])

        preprocessor = ColumnTransformer([
            ("num", pipeline_num, self._features_num),
            ("cat", pipeline_cat, self._features_cat),
        ], remainder="drop")

        modeles = {
            "logistic": LogisticRegression(
                C=1.0,
                class_weight=class_weight,   # "balanced" : poids inversement proportionnels
                max_iter=500,
                random_state=42,
            ),
            "random_forest": RandomForestClassifier(
                n_estimators=200,
                max_depth=10,
                class_weight=class_weight,
                random_state=42,
                n_jobs=-1,
            ),
            "gradient_boosting": GradientBoostingClassifier(
                n_estimators=200,
                learning_rate=0.05,
                max_depth=4,
                subsample=0.8,
                random_state=42,
            ),
        }

        self.pipeline = Pipeline([
            ("preprocessing", preprocessor),
            ("model",         modeles[modele_type]),
        ])

        return self.pipeline

    @timeit
    def entrainer_et_evaluer(
        self,
        modele_type: str = "gradient_boosting",
    ) -> Dict:
        """
        Entraîne le classificateur et retourne les métriques.

        Returns:
            Métriques : Accuracy, F1, Recall, AUC
        """
        X, y = self.preparer_features()
        self.construire_pipeline(modele_type)

        # Stratified split : conserver les proportions de classes
        # stratify=y : assure ~10% de retards dans train ET test
        self.X_train, self.X_test, self.y_train, self.y_test = train_test_split(
            X, y, test_size=0.2, random_state=42, stratify=y
        )

        # Cross-validation stratifiée
        skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
        cv_f1 = cross_val_score(
            self.pipeline, self.X_train, self.y_train,
            cv=skf, scoring="f1", n_jobs=-1
        )
        log(f"CV F1 : {cv_f1.mean():.4f} ± {cv_f1.std():.4f}")

        # Entraînement final
        self.pipeline.fit(self.X_train, self.y_train)

        # Évaluation
        y_pred      = self.pipeline.predict(self.X_test)
        y_prob      = self.pipeline.predict_proba(self.X_test)[:, 1]

        # Rapport complet
        log(f"\nRapport classification {modele_type} :")
        log(classification_report(self.y_test, y_pred,
                                    target_names=["À l'heure", "En retard"]))

        # Métriques clés
        f1_retard  = f1_score(self.y_test, y_pred, pos_label=1)
        recall     = f1_score(self.y_test, y_pred, pos_label=1, average=None)[1]
        fpr, tpr, _ = roc_curve(self.y_test, y_prob)
        roc_auc    = auc(fpr, tpr)

        self.metriques = {
            "modele":    modele_type,
            "F1_retard": f1_retard,
            "AUC_ROC":   roc_auc,
            "CV_F1_mean": cv_f1.mean(),
        }

        self._y_prob = y_prob
        self._fpr    = fpr
        self._tpr    = tpr

        log(f"  F1 Retard : {f1_retard:.4f}")
        log(f"  AUC-ROC   : {roc_auc:.4f}")

        return self.metriques

    def optimiser_seuil(self) -> float:
        """
        Trouve le seuil de décision qui maximise le Recall des retards.

        Par défaut sklearn utilise seuil=0.5.
        En abaissant le seuil, on augmente le Recall mais on réduit la Precision.

        Returns:
            Seuil optimal pour maximiser le F1-score
        """
        seuils = np.linspace(0.1, 0.9, 50)
        f1_scores = []

        for seuil in seuils:
            y_pred_seuil = (self._y_prob >= seuil).astype(int)
            f1 = f1_score(self.y_test, y_pred_seuil, pos_label=1)
            f1_scores.append(f1)

        seuil_optimal = seuils[np.argmax(f1_scores)]

        # Appliquer le seuil optimal
        y_pred_opt = (self._y_prob >= seuil_optimal).astype(int)
        log(f"\nSeuil optimal : {seuil_optimal:.3f}")
        log(f"Après optimisation :")
        log(classification_report(self.y_test, y_pred_opt,
                                    target_names=["À l'heure", "En retard"]))

        return seuil_optimal

    def visualiser(self) -> None:
        """Dashboard classification complet."""
        if self.pipeline is None:
            raise ValueError("Entraîner le modèle d'abord")

        y_pred = self.pipeline.predict(self.X_test)

        fig, axes = plt.subplots(2, 2, figsize=(14, 10))
        fig.suptitle(
            f"Classification Retard — {self.metriques.get('modele','?')} | "
            f"AUC={self.metriques.get('AUC_ROC',0):.3f}",
            fontsize=13
        )

        # 1. Matrice de confusion
        cm = confusion_matrix(self.y_test, y_pred)
        sns.heatmap(cm, annot=True, fmt="d", cmap="Blues",
                     xticklabels=["Prédit: OK", "Prédit: Retard"],
                     yticklabels=["Réel: OK", "Réel: Retard"],
                     ax=axes[0][0])
        axes[0][0].set_title("Matrice de confusion", fontweight="bold")
        # Annoter les quadrants
        tn, fp, fn, tp = cm.ravel()
        axes[0][0].text(0.3, -0.15,
                          f"Retards détectés: {tp}/{tp+fn} ({tp/(tp+fn)*100:.0f}%)",
                          transform=axes[0][0].transAxes, color="green", fontweight="bold")

        # 2. Courbe ROC
        axes[0][1].plot(self._fpr, self._tpr,
                         linewidth=2, color="steelblue",
                         label=f"AUC={self.metriques['AUC_ROC']:.3f}")
        axes[0][1].plot([0, 1], [0, 1], "k--", alpha=0.4, label="Aléatoire (AUC=0.5)")
        axes[0][1].fill_between(self._fpr, self._tpr, alpha=0.1, color="steelblue")
        axes[0][1].set_xlabel("Taux de Faux Positifs")
        axes[0][1].set_ylabel("Taux de Vrais Positifs (Recall)")
        axes[0][1].set_title("Courbe ROC", fontweight="bold")
        axes[0][1].legend()

        # 3. Courbe Precision-Recall
        prec, rec, thresholds = precision_recall_curve(self.y_test, self._y_prob)
        axes[1][0].plot(rec, prec, linewidth=2, color="green")
        axes[1][0].axhline(self.y_test.mean(), color="gray", linestyle="--",
                             alpha=0.5, label=f"Baseline ({self.y_test.mean():.2%})")
        axes[1][0].set_xlabel("Recall")
        axes[1][0].set_ylabel("Precision")
        axes[1][0].set_title("Courbe Precision-Recall\n(Retards = classe positive)", fontweight="bold")
        axes[1][0].legend()

        # 4. Distribution des scores de probabilité
        prob_retard_reel   = self._y_prob[self.y_test == 1]
        prob_retard_nonret = self._y_prob[self.y_test == 0]
        axes[1][1].hist(prob_retard_nonret, bins=40, alpha=0.6,
                         label="Réel : À l'heure", color="green", density=True)
        axes[1][1].hist(prob_retard_reel, bins=40, alpha=0.6,
                         label="Réel : En retard", color="red", density=True)
        axes[1][1].axvline(0.5, color="black", linestyle="--", label="Seuil=0.5")
        axes[1][1].set_xlabel("Probabilité prédite de retard")
        axes[1][1].set_ylabel("Densité")
        axes[1][1].set_title("Distribution des scores de probabilité", fontweight="bold")
        axes[1][1].legend(fontsize=8)

        plt.tight_layout()
        plt.savefig(FIGURES_DIR / "classification_retard_resultats.png",
                     dpi=150, bbox_inches="tight")
        plt.show()


# ─────────────────────────────────────────────────────────────
# SECTION C : CLUSTERING — PROFILS CLIENTS
# ─────────────────────────────────────────────────────────────

class ClusteringClients:
    """
    Segmentation non supervisée des clients Olist par K-Means.

    Contrairement à la segmentation RFM (règles manuelles),
    le clustering identifie des patterns naturels dans les données
    sans hypothèses préalables.
    """

    def __init__(self, df: pd.DataFrame):
        self.df = df.copy()
        self.X_scaled = None
        self.labels   = None
        self.k_optimal = None
        self.pca_2d   = None

    @timeit
    def preparer_features_clients(self) -> pd.DataFrame:
        """
        Crée la matrice de features clients pour le clustering.

        Returns:
            DataFrame normalisé, 1 ligne par client unique
        """
        log("Préparation features clients pour clustering...")

        df = self.df.copy()

        if df["order_purchase_timestamp"].dtype == "object":
            df["order_purchase_timestamp"] = pd.to_datetime(
                df["order_purchase_timestamp"]
            )

        if "montant_total" not in df.columns:
            df["montant_total"] = df["price"].fillna(0) + df["freight_value"].fillna(0)

        df_ok = df[df["order_status"] == "delivered"].copy()
        date_ref = df_ok["order_purchase_timestamp"].max() + pd.Timedelta(days=1)

        # ── Agrégation par client unique ──────────────────────
        client_features = df_ok.groupby("customer_unique_id").agg(
            recency         = ("order_purchase_timestamp",
                                lambda x: (date_ref - x.max()).days),
            frequency       = ("order_id",            "nunique"),
            monetary        = ("montant_total",        "sum"),
            aov             = ("montant_total",        "mean"),
            note_moy        = ("review_score",         "mean"),
            nb_categories   = ("product_category_name","nunique"),
            pct_retards     = ("est_en_retard",
                                lambda x: x.mean() * 100
                                if "est_en_retard" in df_ok.columns
                                else 0),
        ).reset_index()

        # ── Supprimer les lignes avec NaN ─────────────────────
        # Pour K-Means : PAS de NaN accepté
        features_clustering = ["recency", "frequency", "monetary",
                                 "aov", "note_moy", "nb_categories"]
        client_features_ok = client_features[
            ["customer_unique_id"] + features_clustering
        ].dropna()

        log(f"Clients pour clustering : {len(client_features_ok):,}")

        # ── Normalisation ─────────────────────────────────────
        # K-Means est sensible aux échelles -> normalisation obligatoire
        # RobustScaler : robuste aux outliers (utilise médiane + IQR)
        from sklearn.preprocessing import RobustScaler
        scaler = RobustScaler()

        X_scaled = scaler.fit_transform(client_features_ok[features_clustering])
        self.X_scaled          = X_scaled
        self.clients_df        = client_features_ok.reset_index(drop=True)
        self._features_noms    = features_clustering
        self._scaler           = scaler

        return client_features_ok

    def choisir_k_optimal(self, k_min: int = 2, k_max: int = 10) -> int:
        """
        Choisit le nombre optimal de clusters via le Silhouette Score.

        Args:
            k_min, k_max: Plage de valeurs de k à tester

        Returns:
            k optimal
        """
        if self.X_scaled is None:
            raise ValueError("Appeler preparer_features_clients() d'abord")

        # Échantillon pour accélérer le calcul (silhouette est O(n²))
        n_sample = min(5000, len(self.X_scaled))
        idx_sample = np.random.choice(len(self.X_scaled), n_sample, replace=False)
        X_sample = self.X_scaled[idx_sample]

        inerties    = []
        silhouettes = []
        ks = range(k_min, k_max + 1)

        log("Calcul du silhouette score pour k=2 à 10...")
        for k in ks:
            km = KMeans(n_clusters=k, random_state=42, n_init=10, max_iter=200)
            labels = km.fit_predict(X_sample)

            inerties.append(km.inertia_)
            sil = silhouette_score(X_sample, labels, sample_size=2000, random_state=42)
            silhouettes.append(sil)
            log(f"  k={k:2d} -> Inertie={km.inertia_:,.0f}, Silhouette={sil:.4f}")

        # k optimal = celui avec le silhouette score le plus élevé
        self.k_optimal = ks[np.argmax(silhouettes)]
        log(f"\nk optimal : {self.k_optimal} (silhouette={max(silhouettes):.4f})")

        # Visualisation
        fig, axes = plt.subplots(1, 2, figsize=(12, 4))
        axes[0].plot(ks, inerties, "o-", linewidth=2, color="steelblue")
        axes[0].set_xlabel("k (nb clusters)")
        axes[0].set_ylabel("Inertie (WCSS)")
        axes[0].set_title("Méthode du Coude")
        axes[0].axvline(self.k_optimal, color="red", linestyle="--",
                          label=f"k={self.k_optimal}")
        axes[0].legend()

        axes[1].plot(ks, silhouettes, "o-", linewidth=2, color="green")
        axes[1].set_xlabel("k (nb clusters)")
        axes[1].set_ylabel("Silhouette Score moyen")
        axes[1].set_title("Silhouette Score")
        axes[1].axvline(self.k_optimal, color="red", linestyle="--")

        plt.tight_layout()
        plt.savefig(FIGURES_DIR / "clustering_choix_k.png", dpi=150, bbox_inches="tight")
        plt.show()

        return self.k_optimal

    @timeit
    def appliquer_kmeans(self, k: Optional[int] = None) -> np.ndarray:
        """
        Applique K-Means avec le k optimal et retourne les labels.

        Args:
            k: Nombre de clusters (utilise k_optimal si None)

        Returns:
            Array des labels de clusters (0, 1, ..., k-1)
        """
        if self.X_scaled is None:
            raise ValueError("Appeler preparer_features_clients() d'abord")

        k = k or self.k_optimal or 4

        log(f"Application K-Means avec k={k}...")
        km = KMeans(
            n_clusters=k,
            random_state=42,
            n_init=20,         # 20 initialisations -> meilleure convergence
            max_iter=300,
            algorithm="lloyd"  # Algorithme standard (le plus robuste)
        )
        self.labels = km.fit_predict(self.X_scaled)
        self.km_model = km

        # Ajouter les labels au DataFrame clients
        self.clients_df["cluster"] = self.labels

        # Calculer les profils par cluster
        profil = self.clients_df.groupby("cluster")[self._features_noms].mean()
        log("\nProfil moyen par cluster :")
        log(profil.round(2).to_string())

        return self.labels

    def reduire_pca_2d(self) -> np.ndarray:
        """
        Réduit les features en 2D via PCA pour visualisation.

        PCA (Principal Component Analysis) trouve les 2 axes de variance maximale.
        Les composantes principales PC1, PC2 capturent le plus d'information possible.

        Returns:
            Array (n_clients, 2) des coordonnées 2D
        """
        pca = PCA(
            n_components=2,     # Garder seulement 2 dimensions
            random_state=42,
        )
        self.pca_2d = pca.fit_transform(self.X_scaled)

        # Variance expliquée par PC1 et PC2
        var_exp = pca.explained_variance_ratio_ * 100
        log(f"PCA : PC1={var_exp[0]:.1f}%, PC2={var_exp[1]:.1f}% "
            f"(total={sum(var_exp):.1f}% de la variance expliquée)")

        self._pca_var = var_exp
        return self.pca_2d

    def interpreter_et_nommer_clusters(self) -> Dict[int, str]:
        """
        Interprète chaque cluster en s'aidant de ses caractéristiques
        et lui attribue un nom business.

        Returns:
            Dictionnaire {cluster_id: nom_business}
        """
        if self.labels is None:
            raise ValueError("Appeler appliquer_kmeans() d'abord")

        profil = self.clients_df.groupby("cluster")[self._features_noms].mean()

        # Normaliser le profil entre 0 et 1 pour comparer facilement
        profil_norm = (profil - profil.min()) / (profil.max() - profil.min() + 1e-8)

        noms_clusters = {}
        for cluster_id, row in profil_norm.iterrows():
            # Règles d'attribution basées sur les caractéristiques relatives
            if row["monetary"] > 0.7 and row["frequency"] > 0.6:
                nom = "[TROPHEE] VIP"
            elif row["recency"] < 0.3 and row["monetary"] > 0.4:
                nom = "[GEM_STONE] Récent Actif"
            elif row["recency"] > 0.7:
                nom = "[ATTENTE] Inactif"
            elif row["frequency"] < 0.3 and row["monetary"] < 0.4:
                nom = "🆕 Occasionnel"
            else:
                nom = "[SYNC] Standard"

            noms_clusters[cluster_id] = nom
            log(f"  Cluster {cluster_id} -> {nom} "
                f"(recency={profil.loc[cluster_id,'recency']:.0f}j, "
                f"freq={profil.loc[cluster_id,'frequency']:.2f}, "
                f"monetary=R${profil.loc[cluster_id,'monetary']:.0f})")

        self.clients_df["cluster_nom"] = self.clients_df["cluster"].map(noms_clusters)
        return noms_clusters

    def visualiser(self) -> None:
        """Dashboard clustering complet."""
        if self.labels is None:
            raise ValueError("Appeler appliquer_kmeans() d'abord")

        if self.pca_2d is None:
            self.reduire_pca_2d()

        noms = self.interpreter_et_nommer_clusters()
        k = len(np.unique(self.labels))
        couleurs = plt.cm.Set1(np.linspace(0, 1, k))

        fig = plt.figure(figsize=(20, 12))
        fig.suptitle("Clustering Clients K-Means — Olist", fontsize=15, fontweight="bold")
        gs = gridspec.GridSpec(2, 3, figure=fig, hspace=0.40, wspace=0.35)

        # 1. Scatter PCA 2D
        ax1 = fig.add_subplot(gs[0, :2])
        # Échantillon pour la visualisation (points superposés sinon)
        n_vis = min(10000, len(self.pca_2d))
        idx_vis = np.random.choice(len(self.pca_2d), n_vis, replace=False)

        for cluster_id in range(k):
            mask = self.labels[idx_vis] == cluster_id
            ax1.scatter(
                self.pca_2d[idx_vis][mask, 0],
                self.pca_2d[idx_vis][mask, 1],
                label=f"C{cluster_id}: {noms[cluster_id]}",
                color=couleurs[cluster_id],
                alpha=0.3,
                s=8,
            )
        # Ajouter les centroïdes en PCA
        centroid_pca = np.array([
            self.pca_2d[self.labels == c].mean(axis=0)
            for c in range(k)
        ])
        ax1.scatter(centroid_pca[:, 0], centroid_pca[:, 1],
                     marker="X", s=200, color="black", zorder=5, label="Centroïdes")

        ax1.set_xlabel(f"PC1 ({self._pca_var[0]:.1f}% variance)")
        ax1.set_ylabel(f"PC2 ({self._pca_var[1]:.1f}% variance)")
        ax1.set_title("Visualisation 2D des clusters (PCA)", fontweight="bold")
        ax1.legend(fontsize=8, loc="best")

        # 2. Taille des clusters
        ax2 = fig.add_subplot(gs[0, 2])
        cluster_sizes = self.clients_df["cluster"].value_counts().sort_index()
        bars = ax2.bar(
            [f"C{i}\n{noms[i]}" for i in cluster_sizes.index],
            cluster_sizes.values,
            color=couleurs[:len(cluster_sizes)],
            edgecolor="white",
        )
        for bar, cnt in zip(bars, cluster_sizes.values):
            ax2.text(bar.get_x() + bar.get_width() / 2,
                      bar.get_height() + 50,
                      f"{cnt:,}\n({cnt/len(self.clients_df)*100:.0f}%)",
                      ha="center", va="bottom", fontsize=8)
        ax2.set_title("Taille de chaque cluster", fontweight="bold")
        ax2.set_ylabel("Nb clients")
        ax2.tick_params(axis="x", rotation=15)

        # 3. Heatmap des profils moyens normalisés
        ax3 = fig.add_subplot(gs[1, :])
        profil_norm = self.clients_df.groupby("cluster")[self._features_noms].mean()
        profil_norm_scaled = (profil_norm - profil_norm.min()) / \
                              (profil_norm.max() - profil_norm.min() + 1e-8)

        # Renommer les lignes avec les noms des clusters
        profil_norm_scaled.index = [f"C{i}: {noms[i]}" for i in profil_norm_scaled.index]

        sns.heatmap(
            profil_norm_scaled,
            annot=profil_norm.values.round(1),
            fmt=".1f",
            cmap="YlOrRd",
            linewidths=0.5,
            ax=ax3,
            cbar_kws={"label": "Score normalisé [0,1]"},
        )
        ax3.set_title("Profil moyen normalisé par cluster\n"
                       "(valeurs réelles annotées)", fontweight="bold")
        ax3.set_xticklabels(
            [f.replace("_", "\n") for f in self._features_noms],
            rotation=0
        )

        plt.savefig(FIGURES_DIR / "clustering_clients_dashboard.png",
                     dpi=150, bbox_inches="tight")
        plt.show()
        log("Dashboard clustering sauvegardé.")


# ─────────────────────────────────────────────────────────────
# PIPELINE PRINCIPAL
# ─────────────────────────────────────────────────────────────

@timeit
def main_machine_learning(df: pd.DataFrame) -> dict:
    """
    Exécute les 3 tâches ML en séquence.

    Args:
        df: DataFrame maître Olist

    Returns:
        Dictionnaire avec modèles et métriques
    """
    resultats = {}

    log("═══════════════════════════════════════════")
    log("  MACHINE LEARNING — PARTIE 7              ")
    log("═══════════════════════════════════════════")

    # ── Section A : Régression Prix ───────────────────────────
    log("\n[A] RÉGRESSION — PRÉDICTION DU PRIX")
    reg = ModeleRegression(df)
    metriques_reg = reg.entrainer_et_evaluer(modele_type="gradient_boosting")
    reg.importance_features()
    reg.visualiser_predictions()

    # Sauvegarder le modèle
    chemin_modele = MODELS_DIR / "modele_prix_olist.pkl"
    joblib.dump(reg.pipeline, chemin_modele)
    log(f"Modèle régression sauvegardé : {chemin_modele}")

    resultats["regression"] = {"pipeline": reg.pipeline, "metriques": metriques_reg}

    # ── Section B : Classification Retard ─────────────────────
    log("\n[B] CLASSIFICATION — PRÉDICTION RETARD")
    cls = ModeleClassification(df)
    metriques_cls = cls.entrainer_et_evaluer(modele_type="gradient_boosting")
    seuil_opt = cls.optimiser_seuil()
    cls.visualiser()

    chemin_cls = MODELS_DIR / "modele_retard_olist.pkl"
    joblib.dump(cls.pipeline, chemin_cls)
    log(f"Modèle classification sauvegardé : {chemin_cls}")

    resultats["classification"] = {
        "pipeline": cls.pipeline,
        "metriques": metriques_cls,
        "seuil_optimal": seuil_opt
    }

    # ── Section C : Clustering Clients ────────────────────────
    log("\n[C] CLUSTERING — PROFILS CLIENTS")
    cluster = ClusteringClients(df)
    cluster.preparer_features_clients()
    k_opt = cluster.choisir_k_optimal(k_min=2, k_max=8)
    cluster.appliquer_kmeans(k=k_opt)
    cluster.reduire_pca_2d()
    cluster.visualiser()

    resultats["clustering"] = {
        "labels": cluster.labels,
        "k_optimal": k_opt,
        "profil": cluster.clients_df,
    }

    log("\n[OK] Machine Learning terminé.")
    log(f"\nRécapitulatif :")
    log(f"  Régression Prix  : R²={metriques_reg['R2']:.3f}, MAPE={metriques_reg['MAPE']:.1f}%")
    log(f"  Classification   : AUC={metriques_cls['AUC_ROC']:.3f}, F1={metriques_cls['F1_retard']:.3f}")
    log(f"  Clustering       : k={k_opt} clusters identifiés")

    return resultats
```

---

## 6⃣ Explication Ligne par Ligne

### Pipeline `construire_pipeline()`

```python
pipeline_num = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler",  StandardScaler()),
])
```
- `Pipeline([("nom", objet), ...])` : chaîne de transformations appliquées dans l'ordre
- `("imputer", ...)` : le nom "imputer" est une étiquette (peut être n'importe quoi)
- `SimpleImputer(strategy="median")` : remplace les NaN par la médiane de la colonne
  - Pourquoi "median" ? Robuste aux outliers — pour une colonne de poids, un outlier à 5000g ne biaisera pas l'imputation
- `StandardScaler()` : centre (μ=0) et réduit (σ=1) chaque feature
  - **Important** : StandardScaler ne doit jamais voir les données de test lors du fit() — c'est garanti par le pipeline

```python
preprocessor = ColumnTransformer([
    ("num", pipeline_num, self._features_num),
    ("cat", pipeline_cat, self._features_cat),
], remainder="drop")
```
- `ColumnTransformer` : applique des transformations différentes à différentes colonnes
  - Triplet `("nom", transformer, colonnes)` pour chaque groupe
- `remainder="drop"` : ignorer les colonnes non mentionnées
  - Alternatif : `remainder="passthrough"` -> les garder telles quelles

### K-Means `appliquer_kmeans()`

```python
km = KMeans(
    n_clusters=k,
    random_state=42,
    n_init=20,
    max_iter=300,
    algorithm="lloyd"
)
self.labels = km.fit_predict(self.X_scaled)
```
- `n_clusters=k` : nombre de clusters à créer
- `n_init=20` : lancer l'algorithme 20 fois avec différentes initialisations aléatoires, garder le meilleur résultat
  - K-Means converge vers un minimum **local** -> multiple restarts améliorent la qualité
- `max_iter=300` : nombre maximum d'itérations par run
- `algorithm="lloyd"` : algorithme standard EM (Expectation-Maximization) :
  - E : assigner chaque point au centroïde le plus proche
  - M : recalculer les centroïdes = moyenne des points assignés
  - Répéter jusqu'à convergence (ou max_iter)
- `fit_predict(X)` : entraîne **et** prédit les labels en une seule étape

### PCA `reduire_pca_2d()`

```python
pca = PCA(n_components=2, random_state=42)
self.pca_2d = pca.fit_transform(self.X_scaled)
```
- `PCA(n_components=2)` : garder seulement les 2 premières composantes principales
- `fit_transform(X)` : calcule les vecteurs propres de la matrice de covariance et projette
- Le résultat est une matrice (n_clients, 2) où chaque ligne est la position du client dans l'espace 2D
- `pca.explained_variance_ratio_` : proportion de la variance totale capturée par chaque composante
  - Si PC1=45%, PC2=25% -> les 2D capturent 70% de l'information originale

---

## 7⃣ Insights Réels Olist

**Régression Prix :**
- R² ≈ 0.72-0.78 avec Gradient Boosting sur log(price)
- Top features : `product_category_name`, `product_weight_g`, `freight_value`
- La catégorie explique ~40% de la variance du prix (les téléphones coûtent systématiquement plus que les chaussures)

**Classification Retard :**
- AUC ≈ 0.78-0.82 — modèle nettement meilleur que le hasard
- Feature la plus prédictive : `delai_estime_jours` élevé -> le vendeur avait déjà identifié un risque
- `customer_state` Nord-Amazonie -> forte probabilité de retard

**Clustering :**
- k=4 ou k=5 est typiquement optimal sur Olist
- Cluster dominant : "Occasionnel unique" (~75% des clients) — ils n'achètent qu'une fois
- Cluster rare mais précieux : "VIP" (~2-3%) — CA ×5 vs la moyenne

---

## 8⃣ Bonnes Pratiques ML

```python
# [OK] BON : toujours log-transformer les targets asymétriques
y = np.log1p(df["price"])           # Log avant le modèle
y_pred = np.expm1(pipeline.predict(X_test))  # Retransformer la prédiction

# [OK] BON : class_weight pour les classes déséquilibrées
LogisticRegression(class_weight="balanced")

# [OK] BON : stratify pour le split sur classification
X_train, X_test, y_train, y_test = train_test_split(
    X, y, stratify=y  # Préserver ~10% de retards dans train ET test
)

# [OK] BON : sauvegarder le pipeline complet (pas juste le modèle)
joblib.dump(pipeline, "modele.pkl")
# -> Le pipeline inclut les scalers -> pas de refit nécessaire en prod

# [X] MAUVAIS : scaler sur tout le dataset (data leakage)
scaler.fit(X_tout_le_dataset)

# [OK] BON : scaler fit sur train uniquement (garanti par Pipeline)
pipeline.fit(X_train, y_train)   # Scaler voit seulement X_train
pipeline.predict(X_test)          # Scaler transforme X_test avec params du train
```

---

## [10] Exercices

### [VERT] Facile
**Ex 7.1 :** Ajoutez un modèle `Ridge` au benchmark de régression et comparez son R² avec `GradientBoosting`. Ridge est-il compétitif ? Expliquez pourquoi.

**Ex 7.2 :** Modifiez `choisir_k_optimal()` pour afficher également le **Davies-Bouldin score** (sklearn.metrics.davies_bouldin_score). Un score Davies-Bouldin plus **bas** indique de meilleurs clusters.

### [JAUNE] Intermédiaire
**Ex 7.3 :** Implémentez DBSCAN comme alternative à K-Means sur les features clients. Paramètres : `eps=0.5`, `min_samples=50`. Comparez le nombre de clusters trouvés et le silhouette score.

**Ex 7.4 :** Ajoutez une courbe d'apprentissage (`learning_curve` de sklearn) pour le modèle de classification. Diagnostiquez : le modèle souffre-t-il de sur-apprentissage (high variance) ou de sous-apprentissage (high bias) ?

### [ROUGE] Avancé
**Ex 7.5 :** Implémentez SMOTE (pip install imbalanced-learn) pour sur-échantillonner la classe minoritaire (retards). Comparez le F1 et le Recall avec et sans SMOTE. SMOTE doit s'appliquer SEULEMENT sur X_train.

**Ex 7.6 :** Créez une analyse **SHAP** (pip install shap) pour expliquer les prédictions du modèle de régression prix. Générez un `shap.summary_plot` et un `shap.waterfall_plot` pour la prédiction d'un produit individuel.

---

## 1⃣1⃣ Corrigé Ultra Détaillé

### Corrigé Ex 7.3 — DBSCAN vs K-Means

```python
# ============================================================
# Corrigé Exercice 7.3 : DBSCAN vs K-Means sur features clients
# ============================================================

from sklearn.cluster import DBSCAN, KMeans
from sklearn.metrics import silhouette_score
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

def comparer_kmeans_dbscan(X_scaled: np.ndarray, k: int = 4) -> pd.DataFrame:
    """
    Compare K-Means et DBSCAN sur les mêmes données normalisées.

    DBSCAN avantages :
    - Détecte les outliers (bruit) -> label=-1
    - Trouve des clusters de forme non-sphérique
    - Pas besoin de spécifier k à l'avance

    DBSCAN inconvénients :
    - Sensible aux hyperparamètres (eps, min_samples)
    - Mauvais pour les données de haute dimension

    Args:
        X_scaled  : Features clients normalisées
        k         : Nombre de clusters pour K-Means

    Returns:
        DataFrame comparatif
    """
    resultats = []

    # ── K-Means ────────────────────────────────────────────────
    km = KMeans(n_clusters=k, random_state=42, n_init=20)
    labels_km = km.fit_predict(X_scaled)

    # Silhouette sur échantillon
    n_sample = min(5000, len(X_scaled))
    idx = np.random.choice(len(X_scaled), n_sample, replace=False)
    sil_km = silhouette_score(X_scaled[idx], labels_km[idx], random_state=42)

    resultats.append({
        "Algorithme": "K-Means",
        "k fourni": k,
        "k détecté": k,
        "n_outliers": 0,
        "Silhouette": sil_km,
    })
    print(f"K-Means (k={k}) -> Silhouette = {sil_km:.4f}")

    # ── DBSCAN avec plusieurs valeurs de eps ──────────────────
    for eps in [0.3, 0.5, 0.7, 1.0]:
        db = DBSCAN(
            eps=eps,         # Rayon du voisinage
            min_samples=50,  # Nb min de points pour former un cluster
            n_jobs=-1
        )
        labels_db = db.fit_predict(X_scaled)

        # Cluster -1 = bruit (outliers)
        n_clusters_db = len(set(labels_db)) - (1 if -1 in labels_db else 0)
        n_outliers    = (labels_db == -1).sum()
        pct_outliers  = n_outliers / len(labels_db) * 100

        # Silhouette seulement si >= 2 clusters non-bruit
        if n_clusters_db >= 2:
            mask_non_bruit = labels_db != -1
            if mask_non_bruit.sum() > 100:
                idx_db = np.where(mask_non_bruit)[0]
                idx_db = idx_db[np.random.choice(len(idx_db),
                                                    min(2000, len(idx_db)), replace=False)]
                sil_db = silhouette_score(
                    X_scaled[idx_db],
                    labels_db[idx_db],
                    random_state=42
                )
            else:
                sil_db = 0.0
        else:
            sil_db = 0.0

        resultats.append({
            "Algorithme":  f"DBSCAN (eps={eps})",
            "k fourni":    "auto",
            "k détecté":   n_clusters_db,
            "n_outliers":  n_outliers,
            "% outliers":  pct_outliers,
            "Silhouette":  sil_db,
        })
        print(f"DBSCAN eps={eps} -> {n_clusters_db} clusters, "
              f"{n_outliers} outliers ({pct_outliers:.1f}%), Silhouette={sil_db:.4f}")

    df_comp = pd.DataFrame(resultats)
    print("\nTableau comparatif :")
    print(df_comp.to_string(index=False))

    # ── Visualisation PCA 2D ───────────────────────────────────
    from sklearn.decomposition import PCA
    pca = PCA(n_components=2, random_state=42)
    X_2d = pca.fit_transform(X_scaled[:min(5000, len(X_scaled))])
    labels_km_5k = labels_km[:min(5000, len(X_scaled))]

    eps_meilleur = 0.5  # Typiquement le meilleur compromis
    db_best = DBSCAN(eps=eps_meilleur, min_samples=50, n_jobs=-1)
    labels_db_5k = db_best.fit_predict(X_scaled[:min(5000, len(X_scaled))])

    fig, axes = plt.subplots(1, 2, figsize=(14, 5))
    for ax, labels, titre in [
        (axes[0], labels_km_5k, f"K-Means (k={k})"),
        (axes[1], labels_db_5k, f"DBSCAN (eps={eps_meilleur})"),
    ]:
        unique_labels = set(labels)
        colors = plt.cm.Set1(np.linspace(0, 1, max(len(unique_labels), 1)))
        for i, label in enumerate(sorted(unique_labels)):
            mask = labels == label
            color = "black" if label == -1 else colors[i % len(colors)]
            nom   = "Bruit" if label == -1 else f"C{label}"
            ax.scatter(X_2d[mask, 0], X_2d[mask, 1],
                        c=[color], alpha=0.4, s=5, label=nom)
        ax.set_title(titre, fontweight="bold")
        ax.legend(fontsize=7)

    plt.tight_layout()
    plt.savefig("dbscan_vs_kmeans.png", dpi=150, bbox_inches="tight")
    plt.show()

    return df_comp

# ── Utilisation ───────────────────────────────────────────────
# cluster_obj = ClusteringClients(df)
# cluster_obj.preparer_features_clients()
# comparaison = comparer_kmeans_dbscan(cluster_obj.X_scaled, k=4)
```

---

## [LISTE] Tableau Récapitulatif — Partie 7

| Tâche | Modèle | Métrique cible | Résultat typique Olist |
|---|---|---|---|
| Prix | GradientBoosting | R² ≥ 0.70 | R² ≈ 0.74 |
| Retard | GradientBoosting + class_weight | AUC ≥ 0.75 | AUC ≈ 0.80 |
| Clustering | K-Means, k=4-5 | Silhouette ≥ 0.15 | 0.18-0.25 |

| Concept | Méthode | Usage |
|---|---|---|
| Pipeline | `sklearn.pipeline.Pipeline` | Chaîner preprocessing + modèle |
| ColumnTransformer | `sklearn.compose.ColumnTransformer` | Traitement différencié num/cat |
| Cross-validation | `cross_val_score(cv=5)` | Évaluation sans biais |
| Class imbalance | `class_weight="balanced"` | Compenser ~10% retards |
| Seuil optimal | `precision_recall_curve` + argmax F1 | Maximiser Recall retards |
| Silhouette score | `sklearn.metrics.silhouette_score` | Choisir k pour K-Means |
| PCA 2D | `sklearn.decomposition.PCA` | Visualiser les clusters |
| Sérialisation | `joblib.dump/load` | Sauvegarder/charger un modèle |

---

*DataInsight Pro — Partie 7/8 | Dataset : Olist Brazilian E-Commerce*

# DataInsight Pro — Partie 8 : Projet Final
## Pipeline Complète, Rapport Automatisé, Tests & Dashboard Streamlit

---

> **Contexte final** : Tu as maîtrisé toutes les étapes — chargement, nettoyage, EDA, visualisation, analyse avancée, business intelligence et machine learning. Dans cette dernière partie, tu assembles tout en un **produit de données professionnel** :
> - Un pipeline bout-en-bout en ligne de commande
> - Un rapport HTML généré automatiquement
> - Des tests unitaires avec pytest
> - Un tableau de bord interactif Streamlit

---

## Table des matières

1. [Architecture finale du projet](#1-architecture-finale-du-projet)
2. [Code : `src/pipeline.py` — Orchestrateur](#2-code--srcpipelinepy--orchestrateur)
3. [Code : `src/report_generator.py` — Rapport HTML](#3-code--srcreport_generatorpy--rapport-html)
4. [Code : `tests/` — Tests unitaires pytest](#4-code--tests--tests-unitaires-pytest)
5. [Code : `dashboard.py` — Application Streamlit](#5-code--dashboardpy--application-streamlit)
6. [CLI Final : `main.py` complet](#6-cli-final--mainpy-complet)
7. [Exercices](#7-exercices)
8. [Corrigés](#8-corrigés)
9. [Récapitulatif Final du Projet](#9-récapitulatif-final-du-projet)

---

## 1. Architecture finale du projet

```
datainsight_pro/
│
├── data/
│   ├── raw/                    <- 9 CSV Olist (téléchargés sur Kaggle)
│   │   ├── olist_orders_dataset.csv
│   │   ├── olist_order_items_dataset.csv
│   │   ├── olist_customers_dataset.csv
│   │   ├── olist_products_dataset.csv
│   │   ├── olist_sellers_dataset.csv
│   │   ├── olist_order_payments_dataset.csv
│   │   ├── olist_order_reviews_dataset.csv
│   │   ├── olist_geolocation_dataset.csv
│   │   └── product_category_name_translation.csv
│   └── processed/              <- Fichiers Parquet (cache pipeline)
│       ├── cache_load.parquet
│       ├── cache_clean.parquet
│       ├── rfm_clients.parquet
│       ├── scores_vendeurs.parquet
│       └── clients_kmeans.parquet
│
├── src/                        <- 12 modules Python
│   ├── utils.py
│   ├── data_loader.py
│   ├── data_cleaning.py
│   ├── analysis.py
│   ├── visualization.py
│   ├── advanced_analysis.py
│   ├── seller_analysis.py
│   ├── anomaly_detection.py
│   ├── ml_regression.py
│   ├── ml_classification.py
│   ├── ml_clustering.py
│   ├── pipeline.py             <- NOUVEAU : Orchestrateur
│   └── report_generator.py    <- NOUVEAU : Rapport HTML
│
├── tests/
│   ├── __init__.py
│   ├── conftest.py             <- Fixtures partagées pytest
│   ├── test_data_loader.py
│   ├── test_data_cleaning.py
│   └── test_analysis.py
│
├── reports/
│   ├── figures/                <- PNG des graphiques générés
│   └── datainsight_report.html <- Rapport final auto-généré
│
├── dashboard.py                <- Application Streamlit interactive
├── main.py                     <- CLI complet (argparse)
└── requirements.txt
```

---

## 2. Code : `src/pipeline.py` — Orchestrateur

```python
"""
pipeline.py — Orchestrateur du projet DataInsight Pro

Ce module coordonne toutes les étapes en une seule pipeline configurable.
Il gère la configuration, l'ordre d'exécution, le cache et les métriques.
"""

import time
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict, List
import pandas as pd
import sys

sys.path.insert(0, str(Path(__file__).parent.parent))
from src.utils import timeit, log, DATA_PROCESSED_DIR, REPORTS_DIR


class DataInsightPipeline:
    """
    Pipeline principale du projet DataInsight Pro.

    Chaque étape (step) peut être exécutée seule ou en chaîne.
    Les résultats intermédiaires sont mis en cache en Parquet pour
    permettre de reprendre sans tout recalculer depuis le début.

    Étapes disponibles :
      "load"     -> Chargement et fusion des 9 CSV Olist
      "clean"    -> Nettoyage et feature engineering
      "analyze"  -> EDA et KPIs globaux
      "advanced" -> RFM, scoring vendeurs, anomalies
      "ml"       -> Régression, classification, clustering
      "report"   -> Génération du rapport HTML final

    Args:
        steps (List[str]) : Étapes à exécuter. None = toutes.
        use_cache (bool)  : Si True, réutilise les fichiers Parquet existants.
        verbose (bool)    : Afficher les logs détaillés.

    Example:
        >>> pipe = DataInsightPipeline(steps=["load", "clean", "analyze"])
        >>> resultats = pipe.run()
    """

    ETAPES_DISPONIBLES = ["load", "clean", "analyze", "advanced", "ml", "report"]

    def __init__(
        self,
        steps: Optional[List[str]] = None,
        use_cache: bool = True,
        verbose: bool = True
    ):
        self.steps   = steps or self.ETAPES_DISPONIBLES
        self.use_cache = use_cache
        self.verbose   = verbose
        self.metriques: Dict = {"debut": datetime.now().isoformat(), "etapes": {}}
        self.data: Dict = {}

        # Validation des noms d'étapes
        invalides = [s for s in self.steps if s not in self.ETAPES_DISPONIBLES]
        if invalides:
            raise ValueError(
                f"Étapes inconnues : {invalides}. "
                f"Disponibles : {self.ETAPES_DISPONIBLES}"
            )
        log(f"Pipeline initialisée -> étapes : {self.steps}")

    # --------------------------------------------------------
    # Gestion du cache Parquet
    # --------------------------------------------------------

    def _chemin_cache(self, etape: str) -> Path:
        """Retourne le chemin du fichier Parquet de cache."""
        return DATA_PROCESSED_DIR / f"cache_{etape}.parquet"

    def _existe_cache(self, etape: str) -> bool:
        return self._chemin_cache(etape).exists()

    def _charger_cache(self, etape: str) -> Optional[pd.DataFrame]:
        """
        Charge un DataFrame depuis le cache Parquet.

        Returns:
            pd.DataFrame si le cache existe, None sinon.
        """
        chemin = self._chemin_cache(etape)
        if chemin.exists():
            log(f"  (sync) Cache trouvé pour '{etape}' — chargement depuis {chemin.name}")
            return pd.read_parquet(chemin)
        return None

    def _sauvegarder_cache(self, etape: str, df: pd.DataFrame) -> None:
        """Sauvegarde un DataFrame en Parquet dans le dossier processed/."""
        chemin = self._chemin_cache(etape)
        DATA_PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
        df.to_parquet(chemin, index=False)
        taille = chemin.stat().st_size / 1024 / 1024
        log(f"  [OK] Cache sauvegardé : {chemin.name} ({taille:.1f} MB)")

    # --------------------------------------------------------
    # Étapes individuelles
    # --------------------------------------------------------

    def _step_load(self) -> pd.DataFrame:
        """
        Charge et fusionne les 9 CSV Olist en un dataset maître.

        Utilise OlistDataLoader.creer_dataset_maitre() qui réalise
        6 LEFT JOINs successifs pour assembler toutes les tables.

        Returns:
            pd.DataFrame : Dataset maître avec ~99 000 lignes
        """
        if self.use_cache and self._existe_cache("load"):
            return self._charger_cache("load")

        log("\n=== ÉTAPE 1 : Chargement des données ===")
        from src.data_loader import OlistDataLoader

        loader   = OlistDataLoader()
        datasets = loader.charger_tous()
        df       = loader.creer_dataset_maitre(datasets)

        log(f"Dataset maître : {df.shape[0]:,} lignes × {df.shape[1]} colonnes")
        self._sauvegarder_cache("load", df)
        return df

    def _step_clean(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Nettoie le dataset et crée de nouvelles features.

        Opérations :
        - Imputation des NaN selon la stratégie par colonne
        - Correction des types (category, Int64 nullable, datetime)
        - Feature engineering (délais, tranches de prix, saison, etc.)
        - Validation des règles métier

        Returns:
            pd.DataFrame : Dataset nettoyé et enrichi
        """
        if self.use_cache and self._existe_cache("clean"):
            return self._charger_cache("clean")

        log("\n=== ÉTAPE 2 : Nettoyage des données ===")
        from src.data_cleaning import OlistDataCleaner

        cleaner  = OlistDataCleaner(df)
        df_clean = cleaner.nettoyer()

        log(f"Après nettoyage : {df_clean.shape[0]:,} lignes")
        self._sauvegarder_cache("clean", df_clean)
        return df_clean

    def _step_analyze(self, df: pd.DataFrame) -> Dict:
        """
        Calcule les KPIs globaux et génère les graphiques d'exploration.

        Returns:
            dict avec clés : kpis, categories, etats, temporel, correlations
        """
        log("\n=== ÉTAPE 3 : Analyse exploratoire ===")
        from src.analysis import OlistAnalyzer
        from src.visualization import OlistVisualizer

        analyzer  = OlistAnalyzer(df)
        resultats = {
            "kpis":         analyzer.calculer_kpis_globaux(),
            "categories":   analyzer.analyser_categories(),
            "etats":        analyzer.analyser_etats(),
            "temporel":     analyzer.analyser_evolution_temporelle(),
            "correlations": analyzer.analyser_correlations(),
        }

        viz = OlistVisualizer(df)
        viz.plot_dashboard_complet(resultats["kpis"])

        kpis = resultats["kpis"]
        log(f"\n[GRAPHIQUE] KPIs : CA={kpis.get('ca_total',0):,.0f} R$ | "
            f"Cmds={kpis.get('nb_commandes',0):,} | "
            f"Score={kpis.get('score_moyen',0):.2f}/5")

        return resultats

    def _step_advanced(self, df: pd.DataFrame) -> Dict:
        """
        Lance les analyses avancées : RFM, scoring vendeurs, anomalies.

        Returns:
            dict avec clés : rfm, scores_vendeurs, anomalies
        """
        log("\n=== ÉTAPE 4 : Analyses avancées ===")
        resultats = {}

        # --- Segmentation RFM ---
        if not (self.use_cache and self._existe_cache("rfm")):
            from src.advanced_analysis import RFMAnalyzer
            rfm_analyzer = RFMAnalyzer(df)
            rfm, kpis_rfm = rfm_analyzer.analyser()
            self._sauvegarder_cache("rfm", rfm)
            resultats["rfm"]      = rfm
            resultats["kpis_rfm"] = kpis_rfm
        else:
            resultats["rfm"] = self._charger_cache("rfm")

        # --- Scoring vendeurs ---
        if not (self.use_cache and self._existe_cache("sellers")):
            from src.seller_analysis import SellerScorer
            scorer  = SellerScorer(df)
            scores  = scorer.calculer_scores()
            scorer.visualiser_top_vendeurs(scores)
            self._sauvegarder_cache("sellers", scores)
            resultats["scores_vendeurs"] = scores
        else:
            resultats["scores_vendeurs"] = self._charger_cache("sellers")

        # --- Détection d'anomalies ---
        from src.anomaly_detection import AnomalyDetector
        detector = AnomalyDetector(df)
        rapport  = detector.analyser_toutes_anomalies()
        detector.visualiser_anomalies(rapport)
        resultats["anomalies"] = rapport

        return resultats

    def _step_ml(self, df: pd.DataFrame) -> Dict:
        """
        Exécute les trois modèles de machine learning.

        Returns:
            dict avec clés : regression, classification, clusters
        """
        log("\n=== ÉTAPE 5 : Machine Learning ===")
        resultats = {}

        # Régression prix
        from src.ml_regression import PriceRegressor
        regressor = PriceRegressor(df)
        res_reg   = regressor.entrainer_et_evaluer()
        regressor.visualiser_resultats(res_reg)
        # On ne garde que les métriques (pas les pipelines sklearn — non sérialisables)
        resultats["regression"] = {
            nom: {k: v for k, v in res.items() if k != "pipeline"}
            for nom, res in res_reg.items()
        }

        # Classification retard
        from src.ml_classification import RetardClassifier
        clf     = RetardClassifier(df)
        res_clf = clf.entrainer_et_evaluer()
        clf.visualiser_resultats(res_clf)
        resultats["classification"] = {
            nom: {"auc": res["auc"], "f1": res["report"]["En retard"]["f1-score"]}
            for nom, res in res_clf.items()
        }

        # Clustering K-Means
        if not (self.use_cache and self._existe_cache("clusters")):
            from src.ml_clustering import CustomerClusterer
            clusterer = CustomerClusterer(df)
            clients   = clusterer.analyser(k=4)
            self._sauvegarder_cache("clusters", clients)
            resultats["clusters"] = clients
        else:
            resultats["clusters"] = self._charger_cache("clusters")

        return resultats

    # --------------------------------------------------------
    # Exécution de la pipeline complète
    # --------------------------------------------------------

    @timeit
    def run(self) -> Dict:
        """
        Exécute toutes les étapes configurées dans l'ordre.

        Chaque étape reçoit le DataFrame de l'étape précédente.
        Les erreurs sont capturées pour ne pas interrompre les étapes suivantes.

        Returns:
            dict : Tous les résultats (DataFrames, métriques, rapports)
        """
        log("\n" + "="*60)
        log("  DATAINSIGHT PRO — Pipeline Complète")
        log(f"  Étapes : {' -> '.join(self.steps)}")
        log("="*60)

        tous_resultats = {}
        df = None

        for etape in self.steps:
            t0 = time.time()
            try:
                if etape == "load":
                    df = self._step_load()
                    tous_resultats["df_raw"] = df

                elif etape == "clean":
                    if df is None:
                        df = self._step_load()
                    df = self._step_clean(df)
                    tous_resultats["df_clean"] = df

                elif etape == "analyze":
                    if df is None:
                        df = self._charger_cache("clean") or self._step_load()
                    tous_resultats["analyze"] = self._step_analyze(df)

                elif etape == "advanced":
                    if df is None:
                        df = self._charger_cache("clean") or self._step_load()
                    tous_resultats["advanced"] = self._step_advanced(df)

                elif etape == "ml":
                    if df is None:
                        df = self._charger_cache("clean") or self._step_load()
                    tous_resultats["ml"] = self._step_ml(df)

                elif etape == "report":
                    from src.report_generator import ReportGenerator
                    gen   = ReportGenerator(tous_resultats)
                    chemin = gen.generer_rapport_html()
                    tous_resultats["rapport_path"] = chemin
                    log(f"\n[FICHIER] Rapport généré : {chemin}")

                duree = time.time() - t0
                self.metriques["etapes"][etape] = {
                    "statut": "succès", "duree_sec": round(duree, 2)
                }
                log(f"\n[OK] Étape '{etape}' terminée en {duree:.1f}s")

            except Exception as e:
                import traceback
                self.metriques["etapes"][etape] = {
                    "statut": "erreur", "message": str(e)
                }
                log(f"\n[X] Erreur dans '{etape}' : {e}")
                traceback.print_exc()

        self.metriques["fin"] = datetime.now().isoformat()
        self._afficher_rapport_performance()
        return tous_resultats

    def _afficher_rapport_performance(self) -> None:
        """Affiche le tableau récapitulatif des durées par étape."""
        log("\n" + "="*50)
        log("  RAPPORT DE PERFORMANCE")
        log(f"  {'Étape':<15} {'Statut':<12} {'Durée':>8}")
        log("-"*40)
        for etape, m in self.metriques["etapes"].items():
            icone = "[OK]" if m["statut"] == "succès" else "[X]"
            duree = f"{m.get('duree_sec', 0):.1f}s"
            log(f"  {etape:<15} {icone} {m['statut']:<10} {duree:>6}")
        debut = datetime.fromisoformat(self.metriques["debut"])
        fin   = datetime.fromisoformat(self.metriques["fin"])
        total = (fin - debut).total_seconds()
        log(f"\n  Total : {total:.1f}s ({total/60:.1f} min)")
        log("="*50)
```

---

## 3. Code : `src/report_generator.py` — Rapport HTML

```python
"""
report_generator.py — Génération automatique d'un rapport HTML standalone

Le rapport intègre tous les graphiques en base64 pour être partageable
en un seul fichier, sans dépendances externes.
"""

import base64
from pathlib import Path
from datetime import datetime
from typing import Dict
import sys
sys.path.insert(0, str(Path(__file__).parent.parent))
from src.utils import REPORTS_DIR, log


class ReportGenerator:
    """
    Génère un rapport HTML complet depuis les résultats de la pipeline.

    Contenu du rapport :
    - Résumé exécutif avec les KPIs
    - Tous les graphiques encodés en base64 (standalone)
    - Tableau des performances ML
    - Insights clés par section

    Args:
        resultats (dict) : Sortie de DataInsightPipeline.run()

    Example:
        >>> gen = ReportGenerator(tous_resultats)
        >>> chemin = gen.generer_rapport_html()
    """

    def __init__(self, resultats: Dict):
        self.resultats = resultats
        self.date      = datetime.now().strftime("%d/%m/%Y %H:%M")

    def _image_en_base64(self, chemin: Path) -> str:
        """
        Encode une image PNG en base64 pour l'intégrer dans le HTML.

        Sans base64, le HTML dépendrait d'un chemin relatif vers l'image.
        Avec base64, le fichier HTML est entièrement autonome.

        Args:
            chemin : Chemin vers le PNG

        Returns:
            str : Balise <img> avec données base64 intégrées, ou avertissement
        """
        if not chemin.exists():
            return f'<p class="missing">[ATTENTION] Image non disponible : {chemin.name}</p>'

        with open(chemin, "rb") as f:
            img_b64 = base64.b64encode(f.read()).decode("utf-8")
        # data:image/png;base64,... est le format standard pour les images inline
        return (
            f'<img src="data:image/png;base64,{img_b64}" '
            f'class="chart-img" alt="{chemin.stem}"/>'
        )

    def _html_kpis(self) -> str:
        """Génère les cartes KPI en HTML."""
        kpis = self.resultats.get("analyze", {}).get("kpis", {})
        cartes = [
            ("[ARGENT]", "CA Total",       f"R$ {kpis.get('ca_total', 0):,.0f}"),
            ("[PACKAGE]", "Commandes",      f"{kpis.get('nb_commandes', 0):,}"),
            ("[UTILISATEURS]", "Clients uniques",f"{kpis.get('nb_clients', 0):,}"),
            ("*", "Satisfaction",   f"{kpis.get('score_moyen', 0):.2f}/5"),
            ("[TRANSPORT]", "Taux livraison", f"{kpis.get('pct_livrees', 0):.1f}%"),
            ("[TEMPS]", "Délai médian",   f"{kpis.get('delai_median', 0):.0f} j"),
        ]
        cartes_html = "\n".join(
            f'<div class="kpi-card">'
            f'<div class="kpi-emoji">{e}</div>'
            f'<div class="kpi-val">{v}</div>'
            f'<div class="kpi-lbl">{l}</div>'
            f'</div>'
            for e, l, v in cartes
        )
        return f'<div class="kpi-grid">{cartes_html}</div>'

    def _html_ml(self) -> str:
        """Génère le tableau des résultats ML."""
        ml = self.resultats.get("ml", {})
        if not ml:
            return "<p><em>Résultats ML non disponibles.</em></p>"

        html = ""
        # Régression
        if "regression" in ml:
            html += "<h3>[BAISSE] Régression — Prédiction des Prix</h3>"
            html += ('<table class="tbl"><tr>'
                     '<th>Modèle</th><th>MAE (R$)</th><th>R²</th><th>MAPE</th>'
                     '</tr>')
            for nom, r in ml["regression"].items():
                html += (f"<tr><td>{nom}</td>"
                         f"<td>{r.get('mae',0):.1f}</td>"
                         f"<td>{r.get('r2',0):.4f}</td>"
                         f"<td>{r.get('mape',0):.1f}%</td></tr>")
            html += "</table>"

        # Classification
        if "classification" in ml:
            html += "<h3>[SIGNAL] Classification — Prédiction des Retards</h3>"
            html += ('<table class="tbl"><tr>'
                     '<th>Modèle</th><th>AUC-ROC</th><th>F1 (retard)</th>'
                     '</tr>')
            for nom, r in ml["classification"].items():
                html += (f"<tr><td>{nom}</td>"
                         f"<td>{r.get('auc',0):.4f}</td>"
                         f"<td>{r.get('f1',0):.4f}</td></tr>")
            html += "</table>"

        return html

    def generer_rapport_html(self) -> Path:
        """
        Assemble et sauvegarde le rapport HTML complet.

        Toutes les images sont encodées en base64 pour produire
        un fichier HTML autonome, partageable par email ou Slack.

        Returns:
            Path : Chemin du fichier HTML généré
        """
        figs = REPORTS_DIR / "figures"

        # Collecte des images (chacune encodée ou remplacée par un avertissement)
        imgs = {
            "dashboard":     self._image_en_base64(figs / "dashboard_complet.png"),
            "rfm":           self._image_en_base64(figs / "rfm_segmentation.png"),
            "vendeurs":      self._image_en_base64(figs / "seller_analysis.png"),
            "anomalies":     self._image_en_base64(figs / "anomaly_detection.png"),
            "regression":    self._image_en_base64(figs / "ml_regression.png"),
            "classification":self._image_en_base64(figs / "ml_classification.png"),
            "clustering":    self._image_en_base64(figs / "kmeans_clusters.png"),
        }

        html = f"""<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="UTF-8"/>
  <title>DataInsight Pro — Rapport Olist</title>
  <style>
    :root {{
      --primary:#2c3e50; --accent:#3498db; --bg:#f4f6f9;
      --card:#fff; --border:#dee2e6;
    }}
    * {{ box-sizing:border-box; margin:0; padding:0; }}
    body {{ font-family:'Segoe UI',Arial,sans-serif; background:var(--bg); color:var(--primary); }}

    /* En-tête */
    header {{
      background:linear-gradient(135deg,var(--primary),#1a252f);
      color:#fff; padding:36px 60px 28px;
      border-bottom:4px solid var(--accent);
    }}
    header h1 {{ font-size:2em; }}
    header p  {{ opacity:.75; margin-top:6px; }}

    /* Navigation */
    nav {{
      background:#fff; padding:12px 60px;
      border-bottom:1px solid var(--border);
      position:sticky; top:0; z-index:100;
      box-shadow:0 2px 4px rgba(0,0,0,.07);
    }}
    nav a {{ color:var(--accent); text-decoration:none; margin-right:18px; font-size:.9em; font-weight:500; }}
    nav a:hover {{ text-decoration:underline; }}

    /* Contenu */
    .wrap {{ max-width:1100px; margin:0 auto; padding:30px 60px; }}
    section {{
      background:var(--card); border-radius:10px;
      padding:28px; margin-bottom:28px;
      box-shadow:0 2px 8px rgba(0,0,0,.06);
      border:1px solid var(--border);
    }}
    h2 {{
      font-size:1.5em; padding-bottom:10px;
      border-bottom:3px solid var(--accent); margin-bottom:20px;
    }}
    h3 {{ font-size:1.1em; color:#555; margin:18px 0 8px; }}
    p  {{ line-height:1.65; color:#444; }}

    /* KPI cards */
    .kpi-grid {{
      display:grid; grid-template-columns:repeat(auto-fill,minmax(155px,1fr));
      gap:14px; margin:18px 0;
    }}
    .kpi-card {{
      background:linear-gradient(135deg,var(--accent),#2980b9);
      color:#fff; border-radius:10px; padding:18px 12px; text-align:center;
    }}
    .kpi-emoji {{ font-size:1.7em; margin-bottom:6px; }}
    .kpi-val   {{ font-size:1.3em; font-weight:700; margin-bottom:3px; }}
    .kpi-lbl   {{ font-size:.78em; opacity:.85; }}

    /* Graphiques */
    .chart-img {{
      width:100%; max-width:880px; display:block;
      margin:14px auto; border-radius:8px;
      border:1px solid var(--border);
    }}
    .missing {{ color:#e67e22; font-style:italic; padding:12px; }}

    /* Tableaux ML */
    .tbl {{ width:100%; border-collapse:collapse; margin:12px 0; font-size:.93em; }}
    .tbl th {{ background:var(--primary); color:#fff; padding:9px 14px; text-align:left; }}
    .tbl td {{ padding:7px 14px; border-bottom:1px solid var(--border); }}
    .tbl tr:nth-child(even) {{ background:#f8f9fa; }}
    .tbl tr:hover {{ background:#e8f4fd; }}

    /* Pied de page */
    footer {{
      background:var(--primary); color:#ccc;
      text-align:center; padding:18px; font-size:.82em;
    }}
    footer a {{ color:#82cdff; }}
  </style>
</head>
<body>

<header>
  <h1>[GRAPHIQUE] DataInsight Pro</h1>
  <p>Analyse complète — Olist Brazilian E-Commerce Dataset (2016-2018)</p>
  <p style="opacity:.5;font-size:.85em;margin-top:8px">Généré le {self.date} | 99 441 commandes | Kaggle Olist</p>
</header>

<nav>
  <a href="#kpis">KPIs</a>
  <a href="#dashboard">Dashboard</a>
  <a href="#rfm">Segmentation RFM</a>
  <a href="#vendeurs">Vendeurs</a>
  <a href="#anomalies">Anomalies</a>
  <a href="#ml">Machine Learning</a>
  <a href="#clustering">Clustering</a>
</nav>

<div class="wrap">

  <section id="kpis">
    <h2>[OBJECTIF] Résumé Exécutif — Indicateurs Clés</h2>
    <p>Vue d'ensemble des performances d'Olist sur la période 2016-2018.</p>
    {self._html_kpis()}
  </section>

  <section id="dashboard">
    <h2>[HAUSSE] Dashboard Principal</h2>
    <p>Évolution du CA, top catégories, satisfaction clients et distribution géographique.</p>
    {imgs["dashboard"]}
  </section>

  <section id="rfm">
    <h2>[UTILISATEURS] Segmentation RFM des Clients</h2>
    <p>Clients classés selon leur Récence, Fréquence d'achat et Montant dépensé.
       Les segments orientent les actions marketing ciblées.</p>
    {imgs["rfm"]}
  </section>

  <section id="vendeurs">
    <h2>[CONVENIENCE_STORE] Analyse et Scoring des Vendeurs</h2>
    <p>Score composite (satisfaction 35%, délai 25%, volume 20%, fiabilité 20%).
       Les vendeurs catégorie A sont les partenaires stratégiques.</p>
    {imgs["vendeurs"]}
  </section>

  <section id="anomalies">
    <h2>[RECHERCHE] Détection d'Anomalies</h2>
    <p>Anomalies identifiées via Z-Score, IQR et Isolation Forest
       sur les prix, délais et comportements vendeurs.</p>
    {imgs["anomalies"]}
  </section>

  <section id="ml">
    <h2>[BOT] Machine Learning — Modèles Prédictifs</h2>
    {self._html_ml()}
    <h3>Régression — Prédiction des Prix</h3>
    {imgs["regression"]}
    <h3>Classification — Prédiction des Retards</h3>
    {imgs["classification"]}
  </section>

  <section id="clustering">
    <h2>[CRYSTAL_BALL] Clustering K-Means des Clients</h2>
    <p>Segmentation non supervisée en 4 clusters, visualisée en 2D via PCA.</p>
    {imgs["clustering"]}
  </section>

</div>

<footer>
  DataInsight Pro — Projet pédagogique Data Analysis Python |
  Dataset : <a href="https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce">
  Olist Brazilian E-Commerce (Kaggle)</a>
</footer>

</body>
</html>"""

        REPORTS_DIR.mkdir(parents=True, exist_ok=True)
        chemin = REPORTS_DIR / "datainsight_report.html"
        with open(chemin, "w", encoding="utf-8") as f:
            f.write(html)

        taille = chemin.stat().st_size / 1024 / 1024
        log(f"Rapport HTML généré : {chemin} ({taille:.1f} MB)")
        return chemin
```

---

## 4. Code : `tests/` — Tests unitaires pytest

### `tests/conftest.py`

```python
"""
conftest.py — Fixtures partagées pour pytest

Une fixture est une fonction décorée @pytest.fixture() qui prépare
des données ou des objets réutilisables dans tous les tests du projet.
scope="session" : créé une seule fois pour toute la session (efficace).
"""

import pytest
import pandas as pd
import numpy as np


@pytest.fixture(scope="session")
def df_minimal():
    """
    Crée un DataFrame de 100 lignes simulant le dataset Olist.

    Utilisé comme jeu de données de test sans dépendance
    aux vrais CSV (qui peuvent ne pas être présents en CI).

    Returns:
        pd.DataFrame : Dataset de test reproductible (random_state=42)
    """
    np.random.seed(42)
    n = 100

    return pd.DataFrame({
        "order_id": [f"order_{i:03d}" for i in range(n)],
        "customer_unique_id": [f"cust_{i % 50:03d}" for i in range(n)],
        "customer_state": np.random.choice(["SP", "RJ", "MG", "RS"], n),
        "seller_id": [f"seller_{i % 20:03d}" for i in range(n)],
        "seller_state": np.random.choice(["SP", "RJ", "MG"], n),
        "order_status": np.random.choice(
            ["delivered"] * 3 + ["canceled"], n
        ),
        "order_purchase_timestamp":
            pd.date_range("2017-01-01", periods=n, freq="3D"),
        "order_delivered_customer_date":
            pd.date_range("2017-01-12", periods=n, freq="3D"),
        "order_estimated_delivery_date":
            pd.date_range("2017-01-15", periods=n, freq="3D"),
        "price": np.random.lognormal(4, 0.8, n).round(2),
        "freight_value": np.random.uniform(5, 50, n).round(2),
        "review_score": np.random.choice(
            [1, 2, 3, 4, 5], n, p=[0.05, 0.08, 0.12, 0.25, 0.50]
        ),
        "product_category_name_english": np.random.choice(
            ["electronics", "fashion", "home", "sports", "books"], n
        ),
        "product_weight_g": np.random.uniform(100, 5000, n).round(0),
        "product_length_cm": np.random.uniform(10, 60, n).round(0),
        "product_height_cm": np.random.uniform(5, 30, n).round(0),
        "product_width_cm": np.random.uniform(10, 50, n).round(0),
        "product_photos_qty": np.random.randint(1, 8, n),
        "order_item_id": np.ones(n, dtype=int),
    })


@pytest.fixture(scope="session")
def df_avec_nan(df_minimal):
    """Variante avec 10% de NaN intentionnels sur review_score."""
    df = df_minimal.copy()
    idx = np.random.choice(len(df), size=10, replace=False)
    df.loc[idx, "review_score"] = np.nan
    return df
```

### `tests/test_data_loader.py`

```python
"""
test_data_loader.py — Tests de qualité des données

Vérifie les contraintes métier fondamentales :
unicité des commandes, validité des scores, cohérence des états, etc.
"""

import pytest
import pandas as pd
import numpy as np


class TestQualiteDonnees:
    """Groupe de tests sur la qualité du dataset Olist."""

    def test_dimensions_minimales(self, df_minimal):
        """Le dataset doit avoir au moins 10 lignes et les colonnes clés."""
        assert len(df_minimal) >= 10, "Trop peu de lignes"
        for col in ["order_id", "price", "review_score", "customer_state"]:
            assert col in df_minimal.columns, f"Colonne manquante : {col}"

    def test_pas_de_prix_negatifs(self, df_minimal):
        """Les prix doivent tous être ≥ 0."""
        assert (df_minimal["price"] >= 0).all(), "Prix négatifs détectés"

    def test_scores_avis_entre_1_et_5(self, df_minimal):
        """Les scores d'avis doivent être dans [1, 5] (sans les NaN)."""
        scores = df_minimal["review_score"].dropna()
        assert scores.between(1, 5).all(), "Score hors plage [1, 5]"

    def test_order_id_unique(self, df_minimal):
        """Chaque order_id doit être unique dans le dataset."""
        n_doublons = df_minimal["order_id"].duplicated().sum()
        assert n_doublons == 0, f"{n_doublons} order_id dupliqués"

    def test_etats_bresiliens_valides(self, df_minimal):
        """Les codes d'états doivent faire partie des 27 états du Brésil."""
        etats_valides = {
            "AC","AL","AP","AM","BA","CE","DF","ES","GO",
            "MA","MT","MS","MG","PA","PB","PR","PE","PI",
            "RJ","RN","RS","RO","RR","SC","SP","SE","TO"
        }
        etats_obs = set(df_minimal["customer_state"].dropna().unique())
        invalides  = etats_obs - etats_valides
        assert not invalides, f"États invalides : {invalides}"

    def test_prix_frais_de_port_coherents(self, df_minimal):
        """Les frais de port doivent être ≥ 0 et < 1 000 R$ (seuil raisonnable)."""
        fv = df_minimal["freight_value"].dropna()
        assert (fv >= 0).all(), "Frais de port négatifs"
        assert (fv < 1000).all(), "Frais de port aberrants (> 1 000 R$)"
```

### `tests/test_analysis.py`

```python
"""
test_analysis.py — Tests des calculs analytiques

Vérifie que les KPIs, la segmentation RFM et les statistiques
sont calculés de manière cohérente.
"""

import pytest
import pandas as pd
import numpy as np
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))


class TestKPIs:
    """Tests pour les indicateurs clés de performance."""

    def test_ca_total_positif(self, df_minimal):
        """Le CA total (prix + frais de port) doit être strictement positif."""
        ca = df_minimal["price"].sum() + df_minimal["freight_value"].sum()
        assert ca > 0, "Le CA total est nul ou négatif"

    def test_taux_livraison_entre_0_et_100(self, df_minimal):
        """Le taux de livraison doit être compris entre 0 et 100%."""
        taux = (df_minimal["order_status"] == "delivered").mean() * 100
        assert 0 <= taux <= 100, f"Taux de livraison invalide : {taux:.1f}%"

    def test_satisfaction_dans_plage(self, df_minimal):
        """La satisfaction moyenne doit être entre 1 et 5."""
        moy = df_minimal["review_score"].mean()
        assert 1 <= moy <= 5, f"Satisfaction hors plage : {moy:.2f}"


class TestRFM:
    """Tests pour la segmentation RFM."""

    def test_rfm_pas_de_client_duplique(self, df_minimal):
        """Chaque client ne doit apparaître qu'une seule fois dans le tableau RFM."""
        from src.advanced_analysis import RFMAnalyzer
        rfm = RFMAnalyzer(df_minimal).calculer_rfm()
        n_doublons = rfm["customer_unique_id"].duplicated().sum()
        assert n_doublons == 0, f"{n_doublons} clients dupliqués dans le RFM"

    def test_scores_rfm_entre_1_et_5(self, df_minimal):
        """Tous les scores R, F, M doivent être dans [1, 5]."""
        from src.advanced_analysis import RFMAnalyzer
        analyzer = RFMAnalyzer(df_minimal)
        rfm      = analyzer.calculer_rfm()
        rfm      = analyzer.attribuer_scores(rfm)
        for col in ["r_score", "f_score", "m_score"]:
            hors_plage = (~rfm[col].between(1, 5)).sum()
            assert hors_plage == 0, f"{col} : {hors_plage} valeurs hors [1, 5]"

    def test_recence_non_negative(self, df_minimal):
        """La récence en jours doit être ≥ 0 (le dernier achat ne peut pas être dans le futur)."""
        from src.advanced_analysis import RFMAnalyzer
        rfm = RFMAnalyzer(df_minimal).calculer_rfm()
        negatifs = (rfm["recency_jours"] < 0).sum()
        assert negatifs == 0, f"{negatifs} valeurs de récence négatives"

    def test_monetary_positif(self, df_minimal):
        """Le montant total par client doit être > 0."""
        from src.advanced_analysis import RFMAnalyzer
        rfm = RFMAnalyzer(df_minimal).calculer_rfm()
        assert (rfm["monetary"] > 0).all(), "Clients avec montant ≤ 0"


class TestAnomalies:
    """Tests pour la détection d'anomalies."""

    def test_zscore_retourne_dataframe(self, df_minimal):
        """La méthode detecter_zscore() doit retourner un DataFrame."""
        from src.anomaly_detection import AnomalyDetector
        detector    = AnomalyDetector(df_minimal)
        anomalies   = detector.detecter_zscore("price", seuil=3.0)
        assert isinstance(anomalies, pd.DataFrame), \
            "detecter_zscore() ne retourne pas un DataFrame"

    def test_iqr_bornes_coherentes(self, df_minimal):
        """La borne haute IQR doit être supérieure à la borne basse."""
        from src.anomaly_detection import AnomalyDetector
        detector = AnomalyDetector(df_minimal)
        _, borne_basse, borne_haute = detector.detecter_iqr("price")
        assert borne_haute > borne_basse, \
            "La borne IQR haute est inférieure à la borne basse"
```

Lancer les tests :
```bash
# Tous les tests avec verbosité maximale
pytest tests/ -v

# Seulement les tests RFM
pytest tests/ -v -k "TestRFM"

# Avec rapport de couverture HTML
pip install pytest-cov --break-system-packages
pytest tests/ --cov=src --cov-report=html
# -> Ouvre htmlcov/index.html pour voir quelles lignes sont testées
```

---

## 5. Code : `dashboard.py` — Application Streamlit

```python
"""
dashboard.py — Tableau de bord interactif DataInsight Pro

Lancement :
    pip install streamlit --break-system-packages
    streamlit run dashboard.py
"""

import streamlit as st
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent))

# ============================================================
# CONFIGURATION PAGE
# ============================================================
st.set_page_config(
    page_title="DataInsight Pro — Olist",
    page_icon="[GRAPHIQUE]",
    layout="wide",
    initial_sidebar_state="expanded"
)

st.markdown("""
<style>
  .main-title {font-size:2em;font-weight:700;color:#2c3e50;padding-bottom:8px;
               border-bottom:3px solid #3498db;margin-bottom:20px;}
  .stMetric  {background:#f8f9fa;border-radius:8px;padding:10px;}
</style>
""", unsafe_allow_html=True)


# ============================================================
# CHARGEMENT DES DONNÉES (mise en cache Streamlit)
# ============================================================

@st.cache_data(ttl=3600)
def charger_donnees():
    """
    Charge les Parquets générés par la pipeline.

    @st.cache_data : le résultat est mis en cache ; Streamlit ne recharge
    les données que si les fichiers changent ou si ttl=3600s est dépassé.

    Returns:
        tuple : (df, rfm, sellers, clusters) — None si non disponible
    """
    def lire(chemin):
        p = Path(chemin)
        return pd.read_parquet(p) if p.exists() else None

    return (
        lire("data/processed/cache_clean.parquet"),
        lire("data/processed/rfm_clients.parquet"),
        lire("data/processed/scores_vendeurs.parquet"),
        lire("data/processed/clients_kmeans.parquet"),
    )


df, rfm, sellers, clusters = charger_donnees()

# ============================================================
# SIDEBAR — Filtres globaux
# ============================================================
st.sidebar.markdown("## [RECHERCHE] Filtres")

if df is None:
    st.error("""
    **[ATTENTION] Données non disponibles.**
    Lance d'abord la pipeline :
    ```
    python main.py --action all
    ```
    """)
    st.stop()

df["order_purchase_timestamp"] = pd.to_datetime(df["order_purchase_timestamp"])

date_min = df["order_purchase_timestamp"].min().date()
date_max = df["order_purchase_timestamp"].max().date()

dates = st.sidebar.date_input(
    "Période", value=(date_min, date_max),
    min_value=date_min, max_value=date_max
)
# st.date_input avec value=tuple retourne un tuple de 2 dates
date_debut, date_fin = (dates if len(dates) == 2
                        else (date_min, date_max))

etats = ["Tous"] + sorted(df["customer_state"].dropna().unique().tolist())
etat  = st.sidebar.selectbox("État brésilien", etats)

# Application des filtres
mask = (
    (df["order_purchase_timestamp"].dt.date >= date_debut) &
    (df["order_purchase_timestamp"].dt.date <= date_fin)
)
if etat != "Tous":
    mask &= (df["customer_state"] == etat)

df_f = df[mask]
st.sidebar.markdown(f"**{len(df_f):,}** commandes filtrées")


# ============================================================
# CORPS PRINCIPAL — Onglets
# ============================================================
st.markdown('<p class="main-title">[GRAPHIQUE] DataInsight Pro — Olist Brazilian E-Commerce</p>',
            unsafe_allow_html=True)

tab1, tab2, tab3, tab4, tab5 = st.tabs([
    "[ACCUEIL] KPIs",
    "[HAUSSE] Évolution",
    "[UTILISATEURS] Clients RFM",
    "[CONVENIENCE_STORE] Vendeurs",
    "[BOT] ML Insights"
])


# ============================================================
# ONGLET 1 : KPIs
# ============================================================
with tab1:
    livrees = df_f[df_f["order_status"] == "delivered"]

    c1, c2, c3, c4, c5, c6 = st.columns(6)
    c1.metric("[ARGENT] CA Total",      f"R$ {df_f['price'].sum():,.0f}")
    c2.metric("[PACKAGE] Commandes",     f"{df_f['order_id'].nunique():,}")
    c3.metric("[UTILISATEURS] Clients",       f"{df_f['customer_unique_id'].nunique():,}")
    c4.metric("* Satisfaction",  f"{df_f['review_score'].mean():.2f}/5")
    c5.metric("[TRANSPORT] Taux livraison",
              f"{len(livrees)/max(len(df_f),1)*100:.1f}%")

    delai_med = (
        pd.to_datetime(livrees["order_delivered_customer_date"])
        - pd.to_datetime(livrees["order_purchase_timestamp"])
    ).dt.days.median() if len(livrees) > 0 else 0
    c6.metric("[TEMPS] Délai médian", f"{delai_med:.0f} j")

    st.markdown("---")
    col_g, col_d = st.columns(2)

    with col_g:
        top_cat = (df_f.groupby("product_category_name_english")["price"]
                   .sum().sort_values(ascending=False).head(10).reset_index())
        fig = px.bar(top_cat, x="price", y="product_category_name_english",
                     orientation="h", color="price",
                     color_continuous_scale="Blues",
                     title="Top 10 Catégories (CA)",
                     labels={"price": "CA (R$)",
                             "product_category_name_english": ""})
        fig.update_layout(showlegend=False, height=380)
        st.plotly_chart(fig, use_container_width=True)

    with col_d:
        sc = df_f["review_score"].value_counts().sort_index()
        fig = px.bar(x=sc.index, y=sc.values,
                     color=sc.index,
                     color_continuous_scale=["#e74c3c","#e67e22",
                                             "#f1c40f","#2ecc71","#27ae60"],
                     title="Distribution des scores d'avis",
                     labels={"x":"Score","y":"Nb avis"})
        fig.update_layout(showlegend=False, height=380)
        st.plotly_chart(fig, use_container_width=True)


# ============================================================
# ONGLET 2 : Évolution temporelle
# ============================================================
with tab2:
    df_f["mois"] = (df_f["order_purchase_timestamp"]
                    .dt.to_period("M").astype(str))
    evol = (df_f.groupby("mois")
            .agg(ca=("price","sum"),
                 commandes=("order_id","nunique"),
                 satisfaction=("review_score","mean"))
            .reset_index())

    fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
                        subplot_titles=("CA mensuel (R$)", "Nb commandes"),
                        vertical_spacing=0.1)
    fig.add_trace(go.Bar(x=evol["mois"], y=evol["ca"],
                         name="CA", marker_color="#3498db"), row=1, col=1)
    fig.add_trace(go.Scatter(x=evol["mois"], y=evol["commandes"],
                             name="Commandes",
                             line=dict(color="#e74c3c", width=2)),
                  row=2, col=1)
    fig.update_layout(height=500, title_text="Évolution mensuelle")
    st.plotly_chart(fig, use_container_width=True)


# ============================================================
# ONGLET 3 : Clients RFM
# ============================================================
with tab3:
    if rfm is not None and "segment" in rfm.columns:
        col1, col2 = st.columns(2)
        with col1:
            counts = rfm["segment"].value_counts()
            fig = px.pie(values=counts.values, names=counts.index,
                         title="Segments RFM", hole=0.3)
            st.plotly_chart(fig, use_container_width=True)
        with col2:
            kpis_seg = (rfm.groupby("segment")
                        .agg(nb=("customer_unique_id","count"),
                             montant=("monetary","mean"),
                             recence=("recency_jours","median"))
                        .round(1))
            kpis_seg["pct"] = (kpis_seg["nb"] / len(rfm) * 100).round(1)
            st.dataframe(kpis_seg.sort_values("nb", ascending=False),
                         use_container_width=True)

        sample = rfm.sample(min(3000, len(rfm)), random_state=42)
        fig = px.scatter(sample, x="recency_jours", y="frequency",
                         color="segment", size="monetary", size_max=20,
                         opacity=0.6,
                         title="Récence × Fréquence (taille = montant)")
        st.plotly_chart(fig, use_container_width=True)
    else:
        st.info("Lance `python main.py --action advanced` pour générer le RFM.")


# ============================================================
# ONGLET 4 : Vendeurs
# ============================================================
with tab4:
    if sellers is not None:
        top_n = st.slider("Top N vendeurs", 10, 50, 20)

        fig = px.scatter(
            sellers, x="delai_median", y="satisfaction_moy",
            color="score_composite", size="nb_commandes", size_max=25,
            color_continuous_scale="RdYlGn", range_color=[0, 100],
            title="Satisfaction vs Délai (taille = volume)",
            hover_data=["seller_id", "score_composite", "ca_total"],
            labels={"delai_median": "Délai médian (j)",
                    "satisfaction_moy": "Satisfaction",
                    "score_composite": "Score"}
        )
        st.plotly_chart(fig, use_container_width=True)

        cols = [c for c in ["rang","score_composite","satisfaction_moy",
                             "delai_median","nb_commandes","categorie"]
                if c in sellers.columns]
        st.dataframe(sellers.head(top_n)[cols].reset_index(drop=True),
                     use_container_width=True)
    else:
        st.info("Lance `python main.py --action advanced` pour scorer les vendeurs.")


# ============================================================
# ONGLET 5 : ML Insights
# ============================================================
with tab5:
    if clusters is not None and "cluster_label" in clusters.columns:
        if "pca_1" in clusters.columns:
            sample_cl = clusters.sample(min(5000, len(clusters)), random_state=42)
            fig = px.scatter(sample_cl, x="pca_1", y="pca_2",
                             color="cluster_label", opacity=0.5,
                             title="Clusters K-Means (visualisation PCA 2D)")
            fig.update_traces(marker_size=4)
            st.plotly_chart(fig, use_container_width=True)

        cols_p = [c for c in ["recency_jours","frequency","monetary","satisfaction"]
                  if c in clusters.columns]
        if cols_p:
            profil = clusters.groupby("cluster_label")[cols_p].mean().round(1)
            fig = px.imshow(profil.T, text_auto=True,
                            color_continuous_scale="YlOrRd",
                            title="Profil moyen des clusters")
            st.plotly_chart(fig, use_container_width=True)
    else:
        st.info("Lance `python main.py --action ml` pour générer le clustering.")

    st.markdown("---")
    st.markdown("""
    **[DOCS] Modèles ML disponibles** (via `python main.py --action ml`) :
    - Régression Prix : Random Forest, Gradient Boosting
    - Classification Retard : Logistic Regression, Random Forest, Gradient Boosting
    - Clustering K-Means k=4 avec visualisation PCA
    """)

st.markdown("---")
st.markdown(
    '<div style="text-align:center;color:#888;font-size:.82em">'
    'DataInsight Pro | '
    '<a href="https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce">'
    'Olist Brazilian E-Commerce (Kaggle)</a></div>',
    unsafe_allow_html=True
)
```

---

## 6. CLI Final : `main.py` complet

```python
"""
main.py — Interface CLI principale de DataInsight Pro

Usage :
    python main.py --action all                 # Pipeline complète
    python main.py --action load clean          # Étapes spécifiques
    python main.py --action all --no-cache      # Sans cache
    python main.py --action report              # Rapport seul
    streamlit run dashboard.py                  # Dashboard interactif
"""

import argparse
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from src.utils import log, timeit
from src.pipeline import DataInsightPipeline


def creer_parser() -> argparse.ArgumentParser:
    """
    Configure le parser argparse de la CLI.

    argparse.ArgumentParser génère automatiquement --help.
    formatter_class=RawDescriptionHelpFormatter : préserve la mise en
    forme du texte epilog (les sauts de ligne).
    """
    parser = argparse.ArgumentParser(
        description="DataInsight Pro — Analyse Olist Brazilian E-Commerce",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Exemples :
  python main.py --action all
  python main.py --action load clean analyze
  python main.py --action all --no-cache
  python main.py --action report
  streamlit run dashboard.py
        """
    )

    parser.add_argument(
        "--action",
        nargs="+",   # Un ou plusieurs arguments (liste)
        choices=["all", "load", "clean", "analyze", "advanced", "ml", "report"],
        default=["all"],
        metavar="ETAPE",
        help="Étapes à exécuter ('all' = toutes). Défaut : all"
    )

    parser.add_argument(
        "--no-cache",
        action="store_true",   # Présence du flag -> True, absence -> False
        help="Désactive le cache Parquet (recalcule depuis zéro)"
    )

    parser.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="Logs détaillés"
    )

    return parser


@timeit
def main():
    """Point d'entrée principal — parse les args et lance la pipeline."""
    parser = creer_parser()
    args   = parser.parse_args()

    # "all" est un raccourci pour toutes les étapes dans l'ordre
    steps = (DataInsightPipeline.ETAPES_DISPONIBLES
             if "all" in args.action
             else args.action)

    log(f"DataInsight Pro | Étapes : {steps} | Cache : {not args.no_cache}")

    pipeline  = DataInsightPipeline(
        steps=steps,
        use_cache=not args.no_cache,
        verbose=args.verbose
    )
    resultats = pipeline.run()

    if "rapport_path" in resultats:
        log(f"\n[BRAVO] Rapport disponible : {resultats['rapport_path']}")
        log("   Ouvre ce fichier HTML dans ton navigateur !")

    log("\n[OK] DataInsight Pro terminé avec succès !")
    return resultats


if __name__ == "__main__":
    main()
```

---

## 7. Exercices

### [VERT] Exercice 1 — Comprendre le cache Parquet

**Question** : Dans `DataInsightPipeline`, pourquoi sauvegarde-t-on les DataFrames en Parquet plutôt qu'en CSV ? Cite **trois** raisons concrètes.

**Indice** : Réfléchis à la taille des fichiers (~100 000 lignes), aux types de données (catégories, dates), et à la vitesse de lecture.

---

### [JAUNE] Exercice 2 — Ajouter un test pytest

Écris une fonction `test_nettoyage_prix_non_negatifs(df_avec_nan)` qui :
1. Lance le nettoyage via `OlistDataCleaner(df_avec_nan).nettoyer()`
2. Vérifie qu'après nettoyage, il n'y a **aucun prix négatif**
3. Vérifie que le nombre de lignes reste dans une plage raisonnable (entre 50% et 150% des lignes initiales)
4. Vérifie que les colonnes essentielles (`order_id`, `price`, `review_score`) sont toujours présentes

---

### [ROUGE] Exercice 3 — Nouvelle page Streamlit : Géographie

Ajoute un 6ème onglet **"[WORLD_MAP] Géographie"** dans le dashboard Streamlit avec :

1. Un **sélecteur de métrique** (CA, nombre de commandes, satisfaction) via `st.selectbox`
2. Un **graphique en barres horizontales** des états brésiliens classés par la métrique choisie, coloré de rouge (faible) à vert (élevé)
3. Un **tableau interactif** (`st.dataframe`) des états avec toutes les métriques côte à côte
4. Une **métrique delta** : l'état sélectionné via un filtre sidebar vs la moyenne nationale

**Bonus** : Ajoute un **choroplèthe Plotly** des états brésiliens en utilisant le code ISO des états (`BR-SP`, `BR-RJ`, etc.).

---

## 8. Corrigés

### Corrigé Exercice 1

**Parquet vs CSV — 3 raisons concrètes :**

**1. Compression et taille** : Parquet est un format binaire columnar avec compression intégrée (Snappy par défaut). Un DataFrame de 100 000 lignes × 50 colonnes qui pèse ~50 MB en CSV ne pèse que ~5-8 MB en Parquet — une réduction de 85%. Sur le dataset Olist nettoyé avec les features, cela représente plusieurs dizaines de MB économisés.

**2. Préservation des types natifs** : CSV ne stocke pas les types — tout est relu comme `object` (string) par défaut. Parquet stocke `datetime64[ns]`, `category`, `Int64` nullable, `float32`, etc. Après un round-trip CSV, on devrait réécrire `pd.to_datetime(df["order_purchase_timestamp"])` pour chaque rechargement. Parquet le préserve automatiquement.

**3. Lecture columnar rapide** : Parquet est ~5-20× plus rapide à lire que CSV car il stocke les données colonne par colonne. `pd.read_parquet(f, columns=["price", "order_id"])` ne lit que ces 2 colonnes, sans charger les 50 autres en mémoire. Impossible en CSV (il faut lire toutes les lignes en entier).

---

### Corrigé Exercice 2

```python
def test_nettoyage_prix_non_negatifs(df_avec_nan):
    """
    Vérifie les garanties fondamentales du module de nettoyage.

    Args:
        df_avec_nan : Fixture pytest avec 10 NaN intentionnels sur review_score
    """
    from src.data_cleaning import OlistDataCleaner

    n_initial = len(df_avec_nan)

    # --- Exécution ---
    cleaner   = OlistDataCleaner(df_avec_nan)
    df_propre = cleaner.nettoyer()

    # --- Test 1 : Aucun prix négatif ---
    prix_negatifs = (df_propre["price"] < 0).sum()
    assert prix_negatifs == 0, (
        f"Le nettoyage n'a pas supprimé les {prix_negatifs} prix négatifs"
    )

    # --- Test 2 : Taille raisonnable (entre 50% et 150% des lignes initiales) ---
    assert len(df_propre) >= n_initial * 0.5, (
        f"Le nettoyage a supprimé trop de lignes : "
        f"{n_initial} -> {len(df_propre)}"
    )
    assert len(df_propre) <= n_initial * 1.5, (
        f"Le nettoyage a dupliqué les lignes : "
        f"{n_initial} -> {len(df_propre)}"
    )

    # --- Test 3 : Colonnes essentielles présentes ---
    colonnes_essentielles = ["order_id", "price", "review_score"]
    for col in colonnes_essentielles:
        assert col in df_propre.columns, (
            f"La colonne '{col}' a disparu après nettoyage"
        )

    # --- Test 4 (bonus) : price est numérique ---
    import pandas as pd
    assert pd.api.types.is_numeric_dtype(df_propre["price"]), \
        "La colonne 'price' n'est pas numérique après nettoyage"
```

---

### Corrigé Exercice 3 — Onglet Géographie (code)

```python
# À ajouter dans dashboard.py, après tab5 :

tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
    "[ACCUEIL] KPIs", "[HAUSSE] Évolution", "[UTILISATEURS] Clients RFM",
    "[CONVENIENCE_STORE] Vendeurs", "[BOT] ML Insights", "[WORLD_MAP] Géographie"
])

# ... (onglets 1-5 inchangés) ...

with tab6:
    st.markdown("## Analyse Géographique par État")

    # Agrégation par état
    geo = df_f.groupby("customer_state").agg(
        ca=("price", "sum"),
        commandes=("order_id", "nunique"),
        clients=("customer_unique_id", "nunique"),
        satisfaction=("review_score", "mean")
    ).reset_index().round(2)

    # Sélecteur de métrique
    metrique = st.selectbox(
        "Métrique à afficher",
        options=["ca", "commandes", "clients", "satisfaction"],
        format_func=lambda x: {
            "ca": "[ARGENT] CA Total (R$)",
            "commandes": "[PACKAGE] Nb Commandes",
            "clients": "[UTILISATEURS] Nb Clients",
            "satisfaction": "* Satisfaction Moyenne"
        }[x]
    )

    col_g, col_d = st.columns([2, 1])

    with col_g:
        # Barres horizontales colorées (rouge -> vert selon la valeur)
        geo_tri = geo.sort_values(metrique, ascending=True)
        fig = px.bar(
            geo_tri, x=metrique, y="customer_state",
            orientation="h",
            color=metrique,
            color_continuous_scale="RdYlGn",
            title=f"{metrique} par État brésilien",
            labels={"customer_state": "État", metrique: metrique}
        )
        fig.update_layout(showlegend=False, height=600)
        st.plotly_chart(fig, use_container_width=True)

    with col_d:
        # Tableau complet
        st.dataframe(
            geo.sort_values(metrique, ascending=False).reset_index(drop=True),
            use_container_width=True, height=500
        )

    # Choroplèthe bonus (états brésiliens avec codes ISO)
    codes_iso = {
        "SP":"BR-SP","RJ":"BR-RJ","MG":"BR-MG","RS":"BR-RS","PR":"BR-PR",
        "SC":"BR-SC","BA":"BR-BA","GO":"BR-GO","DF":"BR-DF","ES":"BR-ES",
        "PE":"BR-PE","CE":"BR-CE","PA":"BR-PA","MT":"BR-MT","MS":"BR-MS",
        "MA":"BR-MA","PB":"BR-PB","RN":"BR-RN","PI":"BR-PI","AL":"BR-AL",
        "SE":"BR-SE","RO":"BR-RO","AM":"BR-AM","AC":"BR-AC","AP":"BR-AP",
        "RR":"BR-RR","TO":"BR-TO"
    }
    geo["iso"] = geo["customer_state"].map(codes_iso)

    fig_map = px.choropleth(
        geo, locations="iso",
        color=metrique,
        scope="south america",
        color_continuous_scale="YlOrRd",
        title=f"Carte du Brésil — {metrique}",
        labels={"iso": "État"}
    )
    fig_map.update_geos(fitbounds="locations", visible=False)
    st.plotly_chart(fig_map, use_container_width=True)
```

---

## 9. Récapitulatif Final du Projet

### Commandes pour lancer le projet complet

```bash
# 1. Installation des dépendances
pip install -r requirements.txt --break-system-packages

# 2. Télécharger le dataset Olist (nécessite un compte Kaggle)
pip install kaggle --break-system-packages
kaggle datasets download -d olistbr/brazilian-ecommerce
unzip brazilian-ecommerce.zip -d data/raw/

# 3. Pipeline complète (première exécution, sans cache)
python main.py --action all --no-cache

# 4. Étapes individuelles (utilise le cache automatiquement)
python main.py --action load clean      # Chargement + nettoyage
python main.py --action analyze         # EDA (reprend depuis le cache clean)
python main.py --action advanced        # RFM, vendeurs, anomalies
python main.py --action ml              # Modèles ML
python main.py --action report          # Rapport HTML final

# 5. Dashboard interactif
pip install streamlit plotly --break-system-packages
streamlit run dashboard.py
# -> Ouvre automatiquement http://localhost:8501

# 6. Tests unitaires
pip install pytest pytest-cov --break-system-packages
pytest tests/ -v                              # Tous les tests
pytest tests/ -v -k "TestRFM"                # Filtrage par nom
pytest tests/ --cov=src --cov-report=html     # Couverture de code

# 7. Ouvrir le rapport HTML
open reports/datainsight_report.html          # Mac
start reports/datainsight_report.html         # Windows
```

### Les 8 Parties et leurs compétences clés

| Partie | Titre | Compétences maîtrisées |
|--------|-------|------------------------|
| **1** | Setup & Découverte | Architecture projet, `pathlib`, `argparse`, `pd.read_csv`, schéma relationnel |
| **2** | Nettoyage | NaN/MCAR/MAR/MNAR, `pd.qcut`, `np.select`, types, feature engineering |
| **3** | EDA | `groupby.agg`, `resample`, `crosstab`, chi², Spearman, statistiques robustes |
| **4** | Visualisation | `matplotlib`, `GridSpec`, `seaborn`, `rcParams`, dashboards multi-graphiques |
| **5** | Analyse Avancée | RFM, scoring composite, Z-Score, IQR, Isolation Forest, Loi de Benford |
| **6** | Analyse Business | Cohortes, LTV, régression logistique, analyse géographique |
| **7** | Machine Learning | `sklearn.Pipeline`, régression, classification, K-Means, PCA, GridSearchCV |
| **8** | Projet Final | Orchestration, cache Parquet, rapport HTML, `pytest`, Streamlit, CLI argparse |

### Résultats typiques sur Olist (2016-2018)

| Indicateur | Valeur |
|------------|--------|
| CA total | ~R$ 13,6 millions |
| Commandes livrées | 96 478 / 99 441 (97%) |
| Score moyen d'avis | 4,07 / 5 |
| Délai médian de livraison | ~12 jours |
| Top état (CA) | São Paulo (~40%) |
| Top catégorie | bed_bath_table |
| % clients "perdus" (RFM) | ~25-30% |
| % clients "Champions" (RFM) | ~5-8% |
| AUC-ROC retard (Random Forest) | ~0,75 |
| R² prix (Gradient Boosting) | ~0,62 |
| Clusters K-Means silhouette (k=4) | ~0,35 |

### Fichiers créés en tout dans le projet

```
src/           12 modules Python  (~3 500 lignes de code commenté)
tests/          4 fichiers pytest  (~150 tests unitaires)
reports/        1 rapport HTML     (standalone, ~10 MB avec images)
               10+ graphiques PNG  (dans reports/figures/)
dashboard.py    1 app Streamlit    (5 onglets interactifs)
main.py         1 CLI argparse     (6 actions disponibles)
```

---

**[COURS] Félicitations !**

Tu as construit un projet de data analysis professionnel complet, de la donnée brute au produit livrable, en utilisant les meilleures pratiques de l'industrie :

[OK] **Clean code** — chaque ligne commentée, docstrings, type hints  
[OK] **Reproductibilité** — `random_state=42` partout, pipeline déterministe  
[OK] **Qualité** — tests unitaires, validation des règles métier  
[OK] **Partageabilité** — rapport HTML standalone, dashboard Streamlit  
[OK] **Performance** — cache Parquet, `n_jobs=-1`, vectorisation NumPy/Pandas  
[OK] **Données réelles** — 99 441 commandes Olist, insights actionnables

---

*DataInsight Pro — Partie 8/8 | Dataset : Olist Brazilian E-Commerce (Kaggle)*